Compare commits
5 Commits
claude/aud
...
feat/v0.11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa78591dd4 | ||
|
|
283f9f954e | ||
|
|
1b1336a57c | ||
|
|
283fa55979 | ||
|
|
353aa4eee4 |
35
.github/workflows/ci.yml
vendored
35
.github/workflows/ci.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
php: ['8.1', '8.2', '8.3']
|
php: ['7.4', '8.1', '8.3']
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Setup PHP ${{ matrix.php }}
|
- name: Setup PHP ${{ matrix.php }}
|
||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
coverage: none
|
coverage: none
|
||||||
- name: Syntax check all PHP files
|
- name: Syntax check all PHP files
|
||||||
# Exclude .github/mu-plugins — those run on the production server (PHP 8+)
|
# Exclude .github/mu-plugins — those run on the production server (PHP 8+)
|
||||||
# and may use PHP 8+ syntax (union types etc.) that older versions reject.
|
# and may use PHP 8+ syntax (union types etc.) that 7.4 rejects.
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
find . -name '*.php' \
|
find . -name '*.php' \
|
||||||
@@ -44,14 +44,9 @@ jobs:
|
|||||||
phpcs:
|
phpcs:
|
||||||
name: PHPCS (WordPress standards)
|
name: PHPCS (WordPress standards)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
continue-on-error: false # strict after PHPCS cleanup in v0.8.3
|
continue-on-error: false # now strict after PHPCS cleanup in v0.8.3
|
||||||
permissions:
|
|
||||||
contents: write # allows phpcbf auto-fix commits back to the PR branch
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
ref: ${{ github.head_ref || github.ref_name }}
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- name: Setup PHP
|
- name: Setup PHP
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
@@ -60,34 +55,22 @@ jobs:
|
|||||||
tools: composer:v2, phpcs
|
tools: composer:v2, phpcs
|
||||||
- name: Install WordPress Coding Standards
|
- name: Install WordPress Coding Standards
|
||||||
run: |
|
run: |
|
||||||
|
# Allow the PHPCS composer-installer plugin (auto-registers standards).
|
||||||
composer global config --no-plugins allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
|
composer global config --no-plugins allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
|
||||||
|
# WPCS 3.x requires PHPCSExtra (for the Universal.* and NormalizedArrays.* sniffs)
|
||||||
|
# AND phpcsutils. Installing the installer itself lets it auto-register the standards path.
|
||||||
composer global require --no-interaction \
|
composer global require --no-interaction \
|
||||||
"dealerdirect/phpcodesniffer-composer-installer:^1.0" \
|
"dealerdirect/phpcodesniffer-composer-installer:^1.0" \
|
||||||
"wp-coding-standards/wpcs:^3.0" \
|
"wp-coding-standards/wpcs:^3.0" \
|
||||||
"phpcsstandards/phpcsutils:^1.0" \
|
"phpcsstandards/phpcsutils:^1.0" \
|
||||||
"phpcsstandards/phpcsextra:^1.0"
|
"phpcsstandards/phpcsextra:^1.0"
|
||||||
- name: Auto-fix with phpcbf (commit back to branch if changed)
|
- name: Run PHPCS
|
||||||
# phpcbf exits 1 when it makes fixes, 0 when nothing to fix — both are success here.
|
|
||||||
# Commits made via GITHUB_TOKEN do not trigger a new CI run (GitHub prevents loops).
|
|
||||||
run: |
|
|
||||||
phpcbf --standard=phpcs.xml \
|
|
||||||
--extensions=php \
|
|
||||||
--ignore=vendor/*,tests/*,node_modules/* \
|
|
||||||
includes/ prautoblogger.php uninstall.php || true
|
|
||||||
if ! git diff --quiet; then
|
|
||||||
git config user.name "peptiderepo"
|
|
||||||
git config user.email "peptiderepo@users.noreply.github.com"
|
|
||||||
git add -A
|
|
||||||
git commit -m "style: auto-fix PHPCS violations via phpcbf"
|
|
||||||
git push
|
|
||||||
fi
|
|
||||||
- name: Run PHPCS (only non-auto-fixable violations can fail here)
|
|
||||||
run: |
|
run: |
|
||||||
phpcs --standard=phpcs.xml \
|
phpcs --standard=phpcs.xml \
|
||||||
--extensions=php \
|
--extensions=php \
|
||||||
--ignore=vendor/*,tests/*,node_modules/* \
|
--ignore=vendor/*,tests/*,node_modules/* \
|
||||||
--warning-severity=0 \
|
--warning-severity=0 \
|
||||||
--report=full \
|
--report=summary \
|
||||||
includes/ prautoblogger.php uninstall.php
|
includes/ prautoblogger.php uninstall.php
|
||||||
|
|
||||||
phpunit:
|
phpunit:
|
||||||
@@ -96,7 +79,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
php: ['8.1', '8.2', '8.3']
|
php: ['7.4', '8.1', '8.3']
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Setup PHP ${{ matrix.php }}
|
- name: Setup PHP ${{ matrix.php }}
|
||||||
|
|||||||
17
.github/workflows/cto-review.yml
vendored
17
.github/workflows/cto-review.yml
vendored
@@ -123,18 +123,17 @@ jobs:
|
|||||||
git fetch origin ${{ github.event.pull_request.base.ref }} --depth=50
|
git fetch origin ${{ github.event.pull_request.base.ref }} --depth=50
|
||||||
BASE_SHA="${{ github.event.pull_request.base.sha }}"
|
BASE_SHA="${{ github.event.pull_request.base.sha }}"
|
||||||
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
|
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
|
||||||
# Write the diff to a file, not a step output / env var. Passing a
|
DIFF=$(git diff --no-color "$BASE_SHA" "$HEAD_SHA" | head -c 400000)
|
||||||
# ~400KB diff through the process env (execve argv+envp) exceeds the
|
|
||||||
# runner ARG_MAX and fails the next step with "Argument list too long"
|
|
||||||
# (issue #110). File transport also sidesteps the ~1MB step-output cap.
|
|
||||||
git diff --no-color "$BASE_SHA" "$HEAD_SHA" | head -c 400000 > /tmp/cto-review-diff.txt
|
|
||||||
FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")
|
FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")
|
||||||
SIZE=$(wc -c < /tmp/cto-review-diff.txt)
|
SIZE=$(echo -n "$DIFF" | wc -c)
|
||||||
{
|
{
|
||||||
echo "size=$SIZE"
|
echo "size=$SIZE"
|
||||||
echo "files<<EOF_FILES"
|
echo "files<<EOF_FILES"
|
||||||
echo "$FILES"
|
echo "$FILES"
|
||||||
echo "EOF_FILES"
|
echo "EOF_FILES"
|
||||||
|
echo "diff<<EOF_DIFF"
|
||||||
|
echo "$DIFF"
|
||||||
|
echo "EOF_DIFF"
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Load repo conventions
|
- name: Load repo conventions
|
||||||
@@ -162,7 +161,7 @@ jobs:
|
|||||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
REPO_NAME: ${{ github.event.repository.name }}
|
REPO_NAME: ${{ github.event.repository.name }}
|
||||||
DIFF_PATH: /tmp/cto-review-diff.txt
|
DIFF: ${{ steps.diff.outputs.diff }}
|
||||||
DIFF_SIZE: ${{ steps.diff.outputs.size }}
|
DIFF_SIZE: ${{ steps.diff.outputs.size }}
|
||||||
CHANGED_FILES: ${{ steps.diff.outputs.files }}
|
CHANGED_FILES: ${{ steps.diff.outputs.files }}
|
||||||
CONVENTIONS: ${{ steps.conventions.outputs.content }}
|
CONVENTIONS: ${{ steps.conventions.outputs.content }}
|
||||||
@@ -230,9 +229,7 @@ jobs:
|
|||||||
user_blocks.append(f"**Changed files:**\n```\n{os.environ['CHANGED_FILES']}\n```\n")
|
user_blocks.append(f"**Changed files:**\n```\n{os.environ['CHANGED_FILES']}\n```\n")
|
||||||
if os.environ.get("CONVENTIONS", "").strip():
|
if os.environ.get("CONVENTIONS", "").strip():
|
||||||
user_blocks.append(f"# Repo CONVENTIONS.md\n{os.environ['CONVENTIONS']}\n")
|
user_blocks.append(f"# Repo CONVENTIONS.md\n{os.environ['CONVENTIONS']}\n")
|
||||||
with open(os.environ["DIFF_PATH"], encoding="utf-8", errors="replace") as _df:
|
user_blocks.append(f"# Diff\n```diff\n{os.environ['DIFF']}\n```\n")
|
||||||
_diff_text = _df.read()
|
|
||||||
user_blocks.append(f"# Diff\n```diff\n{_diff_text}\n```\n")
|
|
||||||
user_message = "\n\n".join(user_blocks)
|
user_message = "\n\n".join(user_blocks)
|
||||||
|
|
||||||
tools = [{
|
tools = [{
|
||||||
|
|||||||
97
.github/workflows/deploy.yml
vendored
97
.github/workflows/deploy.yml
vendored
@@ -4,20 +4,21 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: deploy-prautoblogger-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate:
|
validate:
|
||||||
name: PHP Lint Gate
|
name: PHP Lint Gate
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout code
|
||||||
- uses: shivammathur/setup-php@v2
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
php-version: '8.1'
|
php-version: '8.1'
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- name: PHP Lint Check
|
- name: PHP Lint Check
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
@@ -28,70 +29,38 @@ jobs:
|
|||||||
-not -path './.github/*' \
|
-not -path './.github/*' \
|
||||||
-print0 | xargs -0 -n1 -P4 php -l > /dev/null
|
-print0 | xargs -0 -n1 -P4 php -l > /dev/null
|
||||||
|
|
||||||
deploy-staging:
|
deploy:
|
||||||
name: Deploy to Staging
|
|
||||||
needs: validate
|
needs: validate
|
||||||
runs-on: ubuntu-latest
|
|
||||||
environment: staging
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Setup SSH key
|
|
||||||
run: |
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
|
|
||||||
chmod 600 ~/.ssh/deploy_key
|
|
||||||
ssh-keyscan -p ${{ secrets.SSH_PORT }} ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
|
||||||
- name: Deploy via rsync → staging
|
|
||||||
run: |
|
|
||||||
rsync -avz --delete \
|
|
||||||
--exclude '.git' --exclude '.github' \
|
|
||||||
--exclude 'tests' --exclude 'phpunit.xml.dist' \
|
|
||||||
--exclude 'composer.json' --exclude 'composer.lock' \
|
|
||||||
--exclude 'vendor' --exclude '.distignore' \
|
|
||||||
--exclude 'ARCHITECTURE.md' --exclude 'CONVENTIONS.md' \
|
|
||||||
-e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }}" \
|
|
||||||
./ ${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }}:~/domains/peptiderepo.com/public_html/staging/wp-content/plugins/prautoblogger/
|
|
||||||
- name: Staging health check
|
|
||||||
run: |
|
|
||||||
sleep 5
|
|
||||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://staging.peptiderepo.com/ || echo "000")
|
|
||||||
echo "Staging responded: HTTP $STATUS"
|
|
||||||
[ "$STATUS" = "000" ] && echo "Warning: staging DNS may still be propagating" || true
|
|
||||||
- name: Cleanup
|
|
||||||
if: always()
|
|
||||||
run: rm -f ~/.ssh/deploy_key
|
|
||||||
|
|
||||||
smoke:
|
|
||||||
name: Smoke tests (staging)
|
|
||||||
needs: deploy-staging
|
|
||||||
uses: peptiderepo/peptide-e2e/.github/workflows/smoke.yml@main
|
|
||||||
secrets:
|
|
||||||
STAGING_WP_ADMIN_USER: ${{ secrets.STAGING_WP_ADMIN_USER }}
|
|
||||||
STAGING_WP_ADMIN_PASSWORD: ${{ secrets.STAGING_WP_ADMIN_PASSWORD }}
|
|
||||||
|
|
||||||
deploy-production:
|
|
||||||
name: Deploy to Production
|
name: Deploy to Production
|
||||||
needs: [validate, deploy-staging]
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: production
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup SSH key
|
- name: Setup SSH key
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
|
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
|
||||||
chmod 600 ~/.ssh/deploy_key
|
chmod 600 ~/.ssh/deploy_key
|
||||||
ssh-keyscan -p ${{ secrets.SSH_PORT }} ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
ssh-keyscan -p ${{ secrets.SSH_PORT }} ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
- name: Deploy via rsync → production
|
|
||||||
|
- name: Deploy via rsync
|
||||||
run: |
|
run: |
|
||||||
rsync -avz --delete \
|
rsync -avz --delete \
|
||||||
--exclude '.git' --exclude '.github' \
|
--exclude '.git' \
|
||||||
--exclude 'tests' --exclude 'phpunit.xml.dist' \
|
--exclude '.github' \
|
||||||
--exclude 'composer.json' --exclude 'composer.lock' \
|
--exclude 'tests' \
|
||||||
--exclude 'vendor' --exclude '.distignore' \
|
--exclude 'phpunit.xml.dist' \
|
||||||
--exclude 'ARCHITECTURE.md' --exclude 'CONVENTIONS.md' \
|
--exclude 'composer.json' \
|
||||||
|
--exclude 'composer.lock' \
|
||||||
|
--exclude 'vendor' \
|
||||||
|
--exclude '.distignore' \
|
||||||
|
--exclude 'ARCHITECTURE.md' \
|
||||||
|
--exclude 'CONVENTIONS.md' \
|
||||||
-e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }}" \
|
-e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }}" \
|
||||||
./ ${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }}:~/domains/peptiderepo.com/public_html/wp-content/plugins/prautoblogger/
|
./ ${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }}:~/domains/peptiderepo.com/public_html/wp-content/plugins/prautoblogger/
|
||||||
|
|
||||||
- name: Purge LiteSpeed Cache
|
- name: Purge LiteSpeed Cache
|
||||||
run: |
|
run: |
|
||||||
ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
|
ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
|
||||||
@@ -99,20 +68,22 @@ jobs:
|
|||||||
"cd ~/domains/peptiderepo.com/public_html && \
|
"cd ~/domains/peptiderepo.com/public_html && \
|
||||||
wp litespeed-purge all 2>/dev/null || \
|
wp litespeed-purge all 2>/dev/null || \
|
||||||
(find wp-content/litespeed/ -type f -delete 2>/dev/null; touch .lscache_purge 2>/dev/null) || true"
|
(find wp-content/litespeed/ -type f -delete 2>/dev/null; touch .lscache_purge 2>/dev/null) || true"
|
||||||
- name: Production health check
|
|
||||||
|
- name: Health check
|
||||||
run: |
|
run: |
|
||||||
sleep 5
|
sleep 5
|
||||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://peptiderepo.com)
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://peptiderepo.com)
|
||||||
|
# 200 = normal healthy response.
|
||||||
|
# 403 = peptide-starter-theme v1.5.2 CF-peer validator rejecting this
|
||||||
|
# GHA-runner direct-to-origin request as non-Cloudflare traffic.
|
||||||
|
# That rejection is the validator working — itself a positive
|
||||||
|
# liveness signal. Real user traffic via Cloudflare gets 200.
|
||||||
if [ "$STATUS" != "200" ] && [ "$STATUS" != "403" ]; then
|
if [ "$STATUS" != "200" ] && [ "$STATUS" != "403" ]; then
|
||||||
echo "ERROR: Site returned unexpected HTTP $STATUS after deploy"
|
echo "ERROR: Site returned unexpected HTTP $STATUS after deploy"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Production healthy (HTTP $STATUS)"
|
echo "Site is responsive (HTTP $STATUS)"
|
||||||
|
|
||||||
- name: Cleanup
|
- name: Cleanup
|
||||||
if: always()
|
if: always()
|
||||||
run: rm -f ~/.ssh/deploy_key
|
run: rm -f ~/.ssh/deploy_key
|
||||||
- name: Deploy summary
|
|
||||||
run: |
|
|
||||||
echo "## Deployment Complete" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "- **Commit:** \`${GITHUB_SHA::7}\`" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "- **Deployed to:** peptiderepo.com" >> $GITHUB_STEP_SUMMARY
|
|
||||||
|
|||||||
22
.github/workflows/main-push-audit.yml
vendored
22
.github/workflows/main-push-audit.yml
vendored
@@ -29,22 +29,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const sha = context.sha;
|
const sha = context.sha;
|
||||||
// Squash-merge -> PR association is eventually-consistent; poll briefly
|
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||||
// before concluding this was a direct push (avoids false positives on merges).
|
owner: context.repo.owner,
|
||||||
async function findMergedPR() {
|
repo: context.repo.repo,
|
||||||
for (let attempt = 1; attempt <= 5; attempt++) {
|
commit_sha: sha,
|
||||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
});
|
||||||
owner: context.repo.owner,
|
const merged = prs.find(p => p.merged_at);
|
||||||
repo: context.repo.repo,
|
|
||||||
commit_sha: sha,
|
|
||||||
});
|
|
||||||
const m = prs.find(p => p.merged_at);
|
|
||||||
if (m) return m;
|
|
||||||
if (attempt < 5) await new Promise(r => setTimeout(r, 10000));
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const merged = await findMergedPR();
|
|
||||||
if (merged) {
|
if (merged) {
|
||||||
core.info(`OK: commit ${sha.substring(0,7)} came from PR #${merged.number}`);
|
core.info(`OK: commit ${sha.substring(0,7)} came from PR #${merged.number}`);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -147,7 +147,6 @@ prautoblogger/
|
|||||||
│ │ ├── class-post-assembler.php # Post-creation helpers: taxonomy, log linking, images, sanitization
|
│ │ ├── class-post-assembler.php # Post-creation helpers: taxonomy, log linking, images, sanitization
|
||||||
│ │ ├── class-image-pipeline.php # Orchestrates A/B image generation (parallel via batch)
|
│ │ ├── class-image-pipeline.php # Orchestrates A/B image generation (parallel via batch)
|
||||||
│ │ ├── class-image-prompt-builder.php # Generates visual prompts from article/source data
|
│ │ ├── class-image-prompt-builder.php # Generates visual prompts from article/source data
|
||||||
│ │ ├── class-image-template-filler.php # Fills the editorial style template's {{ topic_summary }} slot (v0.16.0)
|
|
||||||
│ │ ├── class-image-media-sideloader.php # Imports images into WordPress media library
|
│ │ ├── class-image-media-sideloader.php # Imports images into WordPress media library
|
||||||
│ │ ├── class-cost-tracker.php # Logs all API costs, enforces budget limits
|
│ │ ├── class-cost-tracker.php # Logs all API costs, enforces budget limits
|
||||||
│ │ ├── class-logger.php # Structured logging singleton (error/warning/info/debug)
|
│ │ ├── class-logger.php # Structured logging singleton (error/warning/info/debug)
|
||||||
@@ -438,14 +437,13 @@ All prefixed with `prautoblogger_`:
|
|||||||
| `prautoblogger_schedule_time` | Daily generation time (HH:MM, default: '03:00') |
|
| `prautoblogger_schedule_time` | Daily generation time (HH:MM, default: '03:00') |
|
||||||
| `prautoblogger_image_model` | Image model slug from `Image_Model_Registry::get_models()`; provider is derived from this on save |
|
| `prautoblogger_image_model` | Image model slug from `Image_Model_Registry::get_models()`; provider is derived from this on save |
|
||||||
| `prautoblogger_image_provider` | Derived from the chosen model on save (v0.8.0+); not editable in admin UI |
|
| `prautoblogger_image_provider` | Derived from the chosen model on save (v0.8.0+); not editable in admin UI |
|
||||||
| `prautoblogger_image_prompt_instructions` | System prompt given to the image rewriter LLM (v0.8.0+); falls back to `Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT` when empty. v0.16.0: the default now instructs the LLM to emit a 1-2 sentence editorial topic/mechanism summary as the SCENE (was a comic gag) |
|
| `prautoblogger_image_prompt_instructions` | System prompt given to the image rewriter LLM (v0.8.0+); falls back to `Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT` when empty |
|
||||||
| `prautoblogger_image_nsfw_retry` | Toggle: retry NSFW-blocked image slots with a rule-based fallback prompt (default: '1') |
|
| `prautoblogger_image_nsfw_retry` | Toggle: retry NSFW-blocked image slots with a rule-based fallback prompt (default: '1') |
|
||||||
| `prautoblogger_migrated_image_provider_v080` | One-shot migration flag — auto-heals mismatched provider/model pairs on first admin_init after v0.8.0 upgrade |
|
| `prautoblogger_migrated_image_provider_v080` | One-shot migration flag — auto-heals mismatched provider/model pairs on first admin_init after v0.8.0 upgrade |
|
||||||
| `prautoblogger_article_font_family` | Font family key: 'default', 'inter', 'georgia', 'merriweather', 'lora', 'open_sans', 'roboto', 'system' |
|
| `prautoblogger_article_font_family` | Font family key: 'default', 'inter', 'georgia', 'merriweather', 'lora', 'open_sans', 'roboto', 'system' |
|
||||||
| `prautoblogger_article_font_size` | Body font size in px (0 = theme default). Recommended: 16–18. |
|
| `prautoblogger_article_font_size` | Body font size in px (0 = theme default). Recommended: 16–18. |
|
||||||
| `prautoblogger_table_borders` | Toggle: add borders/padding/striping to tables (default: '1') |
|
| `prautoblogger_table_borders` | Toggle: add borders/padding/striping to tables (default: '1') |
|
||||||
| `prautoblogger_image_style_template` | Full image prompt template (v0.16.0+); contains exactly one `{{ topic_summary }}` token filled per-article with the rewriter scene. Default = `PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE` (editorial scientific illustration). Replaces the Style Suffix. Validated on save (single-token, brief A5) |
|
| `prautoblogger_image_style_suffix` | Text appended to every image prompt (default: CEO-locked 90s infomercial prompt) |
|
||||||
| `prautoblogger_image_style_suffix` | DEPRECATED (v0.16.0). Former comic style suffix appended to every image prompt; no longer read by the builder. Mirrored to `prautoblogger_image_style_suffix_deprecated` for one cycle and not deleted yet |
|
|
||||||
| `prautoblogger_openrouter_model_registry` | Normalized OpenRouter model list (JSON array, daily refresh, serves as durable cache) |
|
| `prautoblogger_openrouter_model_registry` | Normalized OpenRouter model list (JSON array, daily refresh, serves as durable cache) |
|
||||||
| `prautoblogger_openrouter_model_registry_fetched_at` | Unix timestamp of last successful model registry refresh |
|
| `prautoblogger_openrouter_model_registry_fetched_at` | Unix timestamp of last successful model registry refresh |
|
||||||
|
|
||||||
@@ -478,51 +476,7 @@ Stored on every PRAutoBlogger-generated post:
|
|||||||
| Reddit RSS | Primary Reddit data source — Atom feeds for subreddit hot posts | None (unauthenticated) | No known rate limit; reliable from datacenter IPs | `providers/class-reddit-json-client.php` |
|
| Reddit RSS | Primary Reddit data source — Atom feeds for subreddit hot posts | None (unauthenticated) | No known rate limit; reliable from datacenter IPs | `providers/class-reddit-json-client.php` |
|
||||||
| Reddit .json | Fallback for posts + only source for comments | None (unauthenticated) | ~10 req/min (datacenter IPs often blocked) | `providers/class-reddit-json-client.php` |
|
| Reddit .json | Fallback for posts + only source for comments | None (unauthenticated) | ~10 req/min (datacenter IPs often blocked) | `providers/class-reddit-json-client.php` |
|
||||||
| Google Analytics 4 | Post performance metrics | OAuth2 service account | Standard GA4 limits | `core/class-ga4-client.php`, `core/class-metrics-collector.php` |
|
| Google Analytics 4 | Post performance metrics | OAuth2 service account | Standard GA4 limits | `core/class-ga4-client.php`, `core/class-metrics-collector.php` |
|
||||||
| Runware (FLUX.1 via runware.ai) | **Default** image backend (v0.9.0+): schnell ~$0.0006/image, dev ~$0.02/image. True parallel generation via curl_multi. **v0.15.0: live model catalog sync** from `/v1/models` endpoint (task-based API); normalized to Image_Model_Registry shape; cached with 24h TTL; on-demand refresh via AJAX. | API key (encrypted in wp_options) | Account-level quotas | `providers/class-runware-image-provider.php`, `providers/class-runware-image-pricing.php`, `providers/class-runware-image-support.php`, `providers/class-runware-image-batch.php`, `providers/class-runware-model-catalog.php` |
|
| Runware (FLUX.1 via runware.ai) | **Default** image backend (v0.9.0+): schnell ~$0.0006/image, dev ~$0.02/image. True parallel generation via curl_multi. | API key (encrypted in wp_options) | Account-level quotas | `providers/class-runware-image-provider.php`, `providers/class-runware-image-pricing.php`, `providers/class-runware-image-support.php`, `providers/class-runware-image-batch.php` |
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Opik LLM Observability (Optional)
|
|
||||||
|
|
||||||
**Status:** Wave 1 (v0.12.0) — tracing skeleton. Wave 2 (future) — eval harness with frozen-dataset regression testing.
|
|
||||||
|
|
||||||
Opik is a cloud-hosted LLM observability platform (Comet) providing:
|
|
||||||
- **Per-call tracing:** Every OpenRouter call generates a span with model, tokens, latency metadata.
|
|
||||||
- **Trace grouping:** One trace per article generation bundles all spans (draft, QA, image prompt).
|
|
||||||
- **Prompt versioning:** Foundation for Wave 2 — compare new prompt revisions against a frozen reference dataset using LLM-as-judge scoring on rubric axes (factual accuracy, style match, hallucination risk).
|
|
||||||
|
|
||||||
**Architecture:**
|
|
||||||
- **Feature flag:** `prautoblogger_opik_enabled` (WP option, default off). When false, zero network traffic to Opik.
|
|
||||||
- **Credentials:** API key and workspace from PHP constants `PRAUTOBLOGGER_OPIK_API_KEY`, `PRAUTOBLOGGER_OPIK_WORKSPACE` (defined in wp-config.php). Never stored in DB.
|
|
||||||
- **Span lifecycle:**
|
|
||||||
1. `Opik_Trace_Context::init_trace()` generates UUID at article-worker start.
|
|
||||||
2. Each LLM call wraps with `start_span()` → LLM request → `end_span()` with tokens + response.
|
|
||||||
3. `finalize_trace()` + `Opik_Span_Queue::enqueue()` accumulates trace + spans for async dispatch.
|
|
||||||
4. `prautoblogger_opik_dispatch` cron action batches spans/traces (max 100 per POST) to `/v1/private/spans/batch`, `/v1/private/traces/batch`.
|
|
||||||
- **Retry logic:** Exponential backoff (2s, 4s, 8s) on 5xx errors; permanent abort on 4xx; dropped after 3 attempts per item.
|
|
||||||
- **Queue:** WP options-backed; max 1000 items; 12-hour TTL; persists across page reloads for truly async dispatch.
|
|
||||||
|
|
||||||
**Call sites instrumented (Wave 1):**
|
|
||||||
- Draft generation (single-pass or multi-step outline/draft/polish).
|
|
||||||
- Editorial review (chief editor LLM call).
|
|
||||||
- Image prompt generation (deferred; file at 314 LOC, over limit).
|
|
||||||
|
|
||||||
**Cost & Performance:**
|
|
||||||
- Opik free tier: 25k spans/month. Estimated current usage ~4.2k/month (20 posts/day × 7 calls × 30 days). Headroom to 5× growth.
|
|
||||||
- Impact on article generation: async dispatch, zero blocking latency overhead.
|
|
||||||
- Wave 2 (eval harness): adds LLM-as-judge calls (~$14/month at Haiku pricing, assuming 2 eval runs/week).
|
|
||||||
|
|
||||||
**Code structure:**
|
|
||||||
```
|
|
||||||
includes/services/opik/
|
|
||||||
class-opik-client.php — REST client (auth, retry, batch POSTs)
|
|
||||||
class-opik-trace-context.php — per-request trace state singleton
|
|
||||||
class-opik-span-queue.php — WP options-backed async queue
|
|
||||||
class-opik-dispatcher.php — cron handler, batch dispatch
|
|
||||||
|
|
||||||
includes/admin/
|
|
||||||
class-opik-settings.php — admin UI (toggle, project name, status)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -577,16 +531,6 @@ Reddit rejected our OAuth API application (April 2026). We initially switched to
|
|||||||
|
|
||||||
The admin model picker (v1) needs to list OpenRouter models with pricing and capabilities. We fetch `https://openrouter.ai/api/v1/models` (free, unauthenticated) once daily and store the normalized payload in a WP option fronted by a 24h transient. On stale-and-fetch-fails, we serve last-good + surface a warning. The registry class (`class-open-router-model-registry.php`) takes all config via constructor (option name, transient name, endpoint URL) — no PRAUTOBLOGGER_* constants inside the class body. Phase 2 lifts it into a shared Composer package with zero internal edits. Phase 3 adds a parallel `Cloudflare_WorkersAI_Model_Registry` behind the same interface. Capability vocabulary: `text→text`, `text+image→text`, `text+audio→text`, `text→image`, `text→audio`, `text→video`, `text→embedding`. Cost: $0/month.
|
The admin model picker (v1) needs to list OpenRouter models with pricing and capabilities. We fetch `https://openrouter.ai/api/v1/models` (free, unauthenticated) once daily and store the normalized payload in a WP option fronted by a 24h transient. On stale-and-fetch-fails, we serve last-good + surface a warning. The registry class (`class-open-router-model-registry.php`) takes all config via constructor (option name, transient name, endpoint URL) — no PRAUTOBLOGGER_* constants inside the class body. Phase 2 lifts it into a shared Composer package with zero internal edits. Phase 3 adds a parallel `Cloudflare_WorkersAI_Model_Registry` behind the same interface. Capability vocabulary: `text→text`, `text+image→text`, `text+audio→text`, `text→image`, `text→audio`, `text→video`, `text→embedding`. Cost: $0/month.
|
||||||
|
|
||||||
### #20: Editorial illustration prompt + style-template setting (v0.16.0, May 2026)
|
|
||||||
|
|
||||||
Commit 1 of the in-plugin editorial image pipeline (brief `docs/proposals/2026-05-29-image-pipeline-in-plugin-brief.md`). The image style pivots from a single-panel newspaper comic to a **text-free editorial scientific illustration**. Two changes ship here; the deterministic PHP composer is a later commit.
|
|
||||||
|
|
||||||
1. **Prompt structure.** `Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT` now asks the rewriter LLM for a concise 1-2 sentence topic/mechanism summary (one concrete centered focal subject, no text/people-as-gag/logos) as the SCENE, keeping the short CAPTION line for the unchanged HTML-caption-below-image path. The scene/caption parsing contract (`Image_Scene_Parser`) is unchanged.
|
|
||||||
2. **Template substitution.** The old `trim($scene . ' ' . $style_suffix)` concat is replaced by `PRAutoBlogger_Image_Template_Filler::fill($scene)` in all three entry points (`build_article_prompt`, `build_source_prompt`, `build_fallback_prompt`). The filler resolves the active template (admin override → default), strips control chars and clamps the summary length (brief A5), then substitutes the single `{{ topic_summary }}` token. Degradation (brief A5/A6): if the template lacks exactly one token it appends the summary and logs a warning; if the summary is empty it emits the style-only prompt — a blank or token-only prompt is never sent to the provider.
|
|
||||||
3. **Setting.** New textarea `prautoblogger_image_style_template` (default `PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE`) replaces the Style Suffix field. On save it is sanitised with `sanitize_textarea_field` and validated to contain exactly one token (`Image_Template_Filler::sanitize_for_save`). The old `prautoblogger_image_style_suffix` is mirrored to `..._deprecated` for one cycle and not deleted (one-time migration `prautoblogger_migrated_style_template_v0160`).
|
|
||||||
|
|
||||||
Provider/model are unchanged — only the prompt text changes, so production keeps emitting via Runware FLUX.1 schnell but now produces text-free editorial base images.
|
|
||||||
|
|
||||||
### #17: Runware as default image backend (v0.9.0, Apr 2026)
|
### #17: Runware as default image backend (v0.9.0, Apr 2026)
|
||||||
|
|
||||||
After a comic-style A/B round in Apr 2026, FLUX.1 schnell via Runware was chosen as the default image backend. Cost is ~$0.0006/image vs ~$0.039/image for Gemini 2.5 Flash Image via OpenRouter (≈65× cheaper), and the looser schnell fidelity reads as editorial-cartoon style — a feature, not a bug, for our single-panel comic aesthetic. FLUX.1 dev (~$0.02/image, 28 steps) remains available as an opt-in for higher-fidelity runs. The Runware layer mirrors the OpenRouter split (interface + provider + support + pricing + batch), all under the 300-line cap. The batch class dispatches `imageInference` POSTs via `curl_multi_init/exec/select` — wall-clock time for the A/B pair drops to ≈ the slowest single image. A v0.9.0 one-time migration (`prautoblogger_migrated_default_image_v090`) flips sites still on the legacy default; explicit user selections are preserved. Cloudflare Workers AI was removed as an image provider in v0.10.0 (see ADR #16 note below).
|
After a comic-style A/B round in Apr 2026, FLUX.1 schnell via Runware was chosen as the default image backend. Cost is ~$0.0006/image vs ~$0.039/image for Gemini 2.5 Flash Image via OpenRouter (≈65× cheaper), and the looser schnell fidelity reads as editorial-cartoon style — a feature, not a bug, for our single-panel comic aesthetic. FLUX.1 dev (~$0.02/image, 28 steps) remains available as an opt-in for higher-fidelity runs. The Runware layer mirrors the OpenRouter split (interface + provider + support + pricing + batch), all under the 300-line cap. The batch class dispatches `imageInference` POSTs via `curl_multi_init/exec/select` — wall-clock time for the A/B pair drops to ≈ the slowest single image. A v0.9.0 one-time migration (`prautoblogger_migrated_default_image_v090`) flips sites still on the legacy default; explicit user selections are preserved. Cloudflare Workers AI was removed as an image provider in v0.10.0 (see ADR #16 note below).
|
||||||
|
|||||||
236
CHANGELOG.md
236
CHANGELOG.md
@@ -5,242 +5,6 @@ All notable changes to PRAutoBlogger will be documented in this file.
|
|||||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project uses [Semantic Versioning](https://semver.org/).
|
and this project uses [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
## [0.16.0] - 2026-05-29
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Editorial image prompt (Commit 1 of the in-plugin editorial image pipeline).**
|
|
||||||
The image style pivots from a single-panel newspaper comic to a text-free
|
|
||||||
editorial scientific illustration. The rewriter LLM now emits a concise
|
|
||||||
1-2 sentence topic/mechanism summary as the SCENE (one centered focal
|
|
||||||
subject, no text/people-as-gag/logos), keeping the short CAPTION line for the
|
|
||||||
unchanged HTML-caption-below-image behavior. The scene/caption parsing
|
|
||||||
contract is unchanged. Provider/model are unchanged — production keeps
|
|
||||||
generating via Runware FLUX.1 schnell but now produces text-free editorial
|
|
||||||
base images. See `docs/proposals/2026-05-29-image-pipeline-in-plugin-brief.md`.
|
|
||||||
- The prompt builder now substitutes the rewriter scene into an editable style
|
|
||||||
template's `{{ topic_summary }}` slot via the new
|
|
||||||
`PRAutoBlogger_Image_Template_Filler`, replacing the old
|
|
||||||
`scene + style suffix` concatenation, across all three entry points
|
|
||||||
(`build_article_prompt`, `build_source_prompt`, `build_fallback_prompt`).
|
|
||||||
Before substitution the scene is stripped of control characters and
|
|
||||||
length-clamped (brief A5). If the template lacks exactly one token the
|
|
||||||
summary is appended and a warning logged; if the summary is empty the
|
|
||||||
style-only prompt is emitted — a blank or token-only prompt is never sent to
|
|
||||||
the provider (brief A5/A6).
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- New setting **Style Template** (`prautoblogger_image_style_template`,
|
|
||||||
textarea) replacing the Style Suffix field. Default =
|
|
||||||
`PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE` (the editorial template). On save
|
|
||||||
it is validated to contain exactly one `{{ topic_summary }}` token; an invalid
|
|
||||||
template keeps the previous value and surfaces a settings error.
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
- `prautoblogger_image_style_suffix` is deprecated and no longer read by the
|
|
||||||
prompt builder. A one-time migration (`prautoblogger_migrated_style_template_v0160`)
|
|
||||||
mirrors its value to `prautoblogger_image_style_suffix_deprecated` for one
|
|
||||||
version cycle and seeds the new template default. The old option is NOT
|
|
||||||
deleted this cycle.
|
|
||||||
|
|
||||||
## [0.15.1] - 2026-05-14
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Cost preview in AI Models settings tab no longer prints a WordPress database
|
|
||||||
error notice; query now uses canonical column names (`prompt_tokens`,
|
|
||||||
`completion_tokens`, `created_at`). Bug present since v0.11.0 (2026-04-23).
|
|
||||||
|
|
||||||
## [0.14.0] - 2026-04-26
|
|
||||||
## [0.15.0] - 2026-04-26
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Live Runware Model Catalog Sync**: New `class-runware-model-catalog.php`
|
|
||||||
fetches the live Runware model list via their `/v1` endpoint (task-based API),
|
|
||||||
filters to `taskType=imageInference` (text-to-image only, excluding inpainting),
|
|
||||||
normalizes to the Image_Model_Registry shape, and caches in `prautoblogger_runware_model_cache`
|
|
||||||
with a 24-hour TTL. Fixes the pattern from v0.13.8 and v0.14.0 where the
|
|
||||||
hardcoded Runware model list was a maintenance burden and gaps (missing models,
|
|
||||||
wrong prices) went unnoticed until customer images generation broke.
|
|
||||||
- Daily WP-Cron sync (`prautoblogger_sync_runware_models`) scheduled on activation
|
|
||||||
and unscheduled on deactivation.
|
|
||||||
- On-demand "Sync now" AJAX button in PRAdmin (Images settings section) with
|
|
||||||
nonce protection and `manage_options` cap check. Refreshes model count and
|
|
||||||
last-synced timestamp in the UI without full page reload.
|
|
||||||
- Smart caching + fallback strategy: if cache is < 24h old, use it; if stale,
|
|
||||||
trigger a sync; if sync fails, use the last-known-good cache; if no cache,
|
|
||||||
fall back to hardcoded list (never returns empty array).
|
|
||||||
- Pricing merge: Runware API may not expose per-image cost. The catalog pulls
|
|
||||||
costs from `class-runware-image-pricing.php::COST_PER_IMAGE` on every sync,
|
|
||||||
ensuring pricing data is always authoritative and up-to-date.
|
|
||||||
- Unit tests for `PRAutoBlogger_Runware_Model_Catalog`: sync success/failure,
|
|
||||||
fallback on API unreachable, stale cache behavior, cost merge logic.
|
|
||||||
- `class-image-model-registry.php` refactored: `get_models()` now delegates to
|
|
||||||
the catalog for Runware models + static OpenRouter list. Extracted fallback
|
|
||||||
lists into private `get_runware_fallback_models()` and `get_openrouter_models()`
|
|
||||||
for clarity.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Silent model list gaps no longer possible: Runware no longer depends on manual
|
|
||||||
model list maintenance in the pricing class. The live catalog is the source of
|
|
||||||
truth; pricing is merged in on every sync.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- `class-image-model-registry.php`: `get_models()` is now a thin wrapper around
|
|
||||||
the catalog + OpenRouter static list. Breaking change: callers that expect a
|
|
||||||
specific static list should note the new dynamic Runware side.
|
|
||||||
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Runware image generation now correctly uses the admin-selected model instead
|
|
||||||
of silently falling back to FLUX.1 schnell (runware:100@1) for all non-schnell
|
|
||||||
and non-dev selections. Root cause: `class-runware-image-pricing.php` only
|
|
||||||
had 2 models in its `COST_PER_IMAGE` whitelist (schnell + dev), but the
|
|
||||||
image model registry was expanded to 15 Runware models in v0.13.8 without a
|
|
||||||
matching update to the pricing class. Any model ID not in the whitelist caused
|
|
||||||
`resolve_model()` to fall back to schnell, so every Stable Diffusion, FLUX.2,
|
|
||||||
HiDream, TwinFlow, Z-Image, Qwen-Image, Krea, and GLM-Image selection was
|
|
||||||
silently downgraded.
|
|
||||||
- Added all 15 Runware model IDs to `COST_PER_IMAGE` and `DEFAULT_STEPS` with
|
|
||||||
correct per-image costs and step counts sourced from runware.ai/pricing (April 2026).
|
|
||||||
- Added `@see` cross-reference to `class-image-model-registry.php` in pricing
|
|
||||||
class docblock to prevent future drift.
|
|
||||||
|
|
||||||
## [0.13.9] - 2026-04-26
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Model picker sort by price now works correctly for image models — previously
|
|
||||||
\`applySort\` always read \`input_price_per_m\` (undefined on image models),
|
|
||||||
so every image had an effective price of 0 and price sort was a no-op.
|
|
||||||
Fix: detect image models via \`cost_per_image\` and use that field instead.
|
|
||||||
|
|
||||||
## [0.13.8] - 2026-04-26
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Image model picker expanded from 7 to 21 models
|
|
||||||
- New Runware text-to-image models: Stable Diffusion 3, HiDream-I1 Full/Dev/Fast,
|
|
||||||
FLUX.1 Krea [dev], Qwen-Image, FLUX.2 [dev], FLUX.2 [klein] 9B, FLUX.2 [klein] 4B,
|
|
||||||
GLM-Image, Z-Image, Z-Image-Turbo, TwinFlow Z-Image-Turbo
|
|
||||||
- New OpenRouter model: openai/gpt-5.4-image-2
|
|
||||||
- Registry now includes last-verified date and update instructions in source comment
|
|
||||||
|
|
||||||
|
|
||||||
All notable changes to PRAutoBlogger will be documented in this file.
|
|
||||||
|
|
||||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
||||||
and this project uses [Semantic Versioning](https://semver.org/).
|
|
||||||
|
|
||||||
## [0.13.7] - 2026-04-24
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Posts list column layout: title was set to 35% which, combined with 3 fixed-width PRAB columns, left ~50px for every remaining column (Author, Categories, Tags, Date all word-wrapping). Replaced with a balanced budget: title 22%, author 6%, categories 7%, tags 9%, date 8%, PRAB columns 85/85/50px, Peptide Topics 75px.
|
|
||||||
|
|
||||||
## [0.13.6] - 2026-04-24
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Peptide Topics column still rendering vertically: WordPress posts table uses `table-layout: fixed` which ignores `min-width` entirely. Changed to explicit `width: 80px` which is respected under fixed-layout tables.
|
|
||||||
|
|
||||||
## [0.13.5] - 2026-04-24
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Cost column in Posts list was always showing $0.00: a PHP `// phpcs:ignore` comment was embedded inside the SQL string literal, causing a MariaDB syntax error on every query. Moved comment above the `prepare()` call.
|
|
||||||
- Peptide Topics column header in Posts list was being squeezed to near-zero width by the fixed-width PRAB columns. Added `min-width: 80px` to `.column-taxonomy-peptide_topic`.
|
|
||||||
|
|
||||||
## [0.13.4] - 2026-04-24
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Model picker price column now consistently right-aligned. Root cause: price and description were siblings inside .ab-mp-row-meta, with description first and variable-length — this shifted price left/right per row. Fix: moved price to last position with flex-shrink: 0 so its right edge is always pinned to the row edge; capped description at max-width 200px with ellipsis; swapped column headers to match new DOM order.
|
|
||||||
|
|
||||||
## [0.13.3] - 2026-04-24
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Price column header now right-aligned with `min-width: 90px` to match data cells — header and prices now track the same column edge.
|
|
||||||
|
|
||||||
## [0.13.2] - 2026-04-24
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Model picker: added sortable column headers (Model, Price, Context); clicking a header sorts the list ascending/descending; active column highlighted with sort arrow; sort preference persists while the picker is open.
|
|
||||||
|
|
||||||
## [0.12.3] — 2026-04-24
|
|
||||||
## [0.13.1] - 2026-04-24
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Model picker price column now right-aligned with a minimum width so values like `$0.0006/image` and `$0.0800/image` stack cleanly instead of floating at uneven offsets.
|
|
||||||
|
|
||||||
## [0.13.0] - 2026-04-24
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Wave 2 Eval Harness**: Complete evaluation framework for PRAutoBlogger output quality
|
|
||||||
- Frozen eval dataset (`eval/dataset.json`) with 20 curated peptide topics
|
|
||||||
- LLM-as-judge scorer (`class-opik-eval-scorer.php`) using Gemini Flash 1.5
|
|
||||||
- Eval runner orchestrator (`class-opik-eval-runner.php`) with Opik experiment support
|
|
||||||
- WP-CLI command: `wp prautoblogger opik:eval [--limit=<n>] [--dry-run]`
|
|
||||||
- Image pipeline instrumentation: Opik span wrapping for `image_prompt_rewrite` step
|
|
||||||
- **Image Prompt Builder Refactor**: Extracted scene/caption parsing into `PRAutoBlogger_Image_Scene_Parser`
|
|
||||||
- Three static methods: `parse_scene_and_caption()`, `extract_first_paragraph()`, `synthesize_visual_concepts_fallback()`
|
|
||||||
- Cleaner separation of concerns; builder now ~255 lines (was 331)
|
|
||||||
- **Content Generator Eval Mode**: Added `$eval_mode` parameter to suppress publishing and image generation side effects
|
|
||||||
- **Trace Context Integration**: Optional trace instrumentation in `PRAutoBlogger_Image_Prompt_Builder` constructor
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- `PRAutoBlogger_Image_Prompt_Builder` constructor now accepts optional `PRAutoBlogger_Opik_Trace_Context` parameter
|
|
||||||
- `PRAutoBlogger_Image_Pipeline` constructor passes trace context through to prompt builder
|
|
||||||
- Image prompt rewrite LLM calls now emit Opik spans when trace context is available
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Image prompt builder now properly delegates to static parser methods
|
|
||||||
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Opik autoloader gap** — `class-autoloader.php` listed `services/` as a scan directory but not `services/opik/`, where all four Opik classes live (`Opik_Client`, `Opik_Trace_Context`, `Opik_Span_Queue`, `Opik_Dispatcher`). Composer's classmap (used in PHPUnit) covers `includes/` recursively so tests passed, but the WordPress runtime autoloader would fatal on first Opik class reference in production. Added `services/opik/` to the scan list.
|
|
||||||
|
|
||||||
|
|
||||||
## [0.12.2] — 2026-04-24
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Image style suffix empty-string bypass** — `get_option()` for `prautoblogger_image_style_suffix` only returns the default constant when the key is absent from `wp_options`; an empty string stored in the DB returns `''`, bypassing fallback entirely. Root cause of post #675 rendering photorealistically instead of as a newspaper comic. Fixed all three call sites in `class-image-prompt-builder.php` (lines 85, 107, 272) by extracting a private helper `get_style_suffix()` that treats both absent AND empty string as "use default".
|
|
||||||
|
|
||||||
- **Image cost logged under wrong run_id** — `class-image-pipeline.php` created a fresh cost tracker when invoked without an argument, so image costs were logged under a different `run_id` than the article's other stages. Cost breakdowns aggregating by `run_id` silently excluded the image line item. Fixed by threading the article worker's cost tracker through `PRAutoBlogger_Publisher` and `PRAutoBlogger_Post_Assembler::attach_generated_images()` to the image pipeline constructor. Image costs now share the same `run_id` as analysis, draft, review, and llm_research stages.
|
|
||||||
|
|
||||||
|
|
||||||
## [0.12.1] — 2026-04-23
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Opik UUID v7 bug** — `PRAutoBlogger_Opik_Trace_Context` was generating trace and
|
|
||||||
span IDs with `wp_generate_uuid4()` (UUID v4). Opik's REST API requires UUID v7 and
|
|
||||||
rejects v4 with HTTP 400 "Trace id must be a version 7 UUID". Added a private static
|
|
||||||
`generate_uuid7()` method using `microtime()` + `random_bytes()` and replaced both
|
|
||||||
call sites. All trace and span submissions now succeed.
|
|
||||||
|
|
||||||
## [0.12.0] — 2026-04-23
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Opik LLM observability integration (Wave 1: tracing skeleton).** Cloud-hosted observability via Comet Opik for detailed per-call tracing and prompt-version regression testing. Captured via async fire-and-forget dispatch to avoid blocking article generation.
|
|
||||||
|
|
||||||
- **Opik REST client.** Minimal HTTP client (no third-party SDK) with exponential backoff retry, batch endpoints, and credential isolation (API key from wp-config.php constants, never in DB).
|
|
||||||
|
|
||||||
- **Per-request trace context singleton.** Holds trace ID + span stack for one article generation. UUID threading across draft generation, editorial review, and image prompt generation.
|
|
||||||
|
|
||||||
- **Async span/trace queue.** WP options-backed queue with TTL expiry (12 hours) and max depth (1000 items). Drained by cron dispatcher with per-batch retry logic.
|
|
||||||
|
|
||||||
- **Cron dispatcher.** `prautoblogger_opik_dispatch` action scheduled via `wp_schedule_single_event()` drains queue in batches of up to 100 spans.
|
|
||||||
|
|
||||||
- **Opik admin settings section.** Toggle to enable/disable tracing (default off), project name field, opt-in full-prompt capture, read-only status display (API key configured, last dispatch, queue depth).
|
|
||||||
|
|
||||||
- **Span instrumentation at four LLM call sites:** single-pass article generation, outline/draft/polish stages (multi-step), and editorial review. Each span captures model name, provider, usage tokens.
|
|
||||||
|
|
||||||
- **Comprehensive unit test suite.** 4 test files covering client (auth, retry, batch limits), trace context (UUID generation, span stacking), queue (enqueue/dequeue, expiry, reenqueue), and dispatcher (batch POST, separation of traces vs. spans).
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **class-article-worker.php:** Initializes Opik trace context at generation start, enqueues finalized trace + spans at end if feature enabled.
|
|
||||||
|
|
||||||
- **class-content-generator.php:** Wraps single-pass and multi-step stages (outline, draft, polish) with Opik spans capturing token usage.
|
|
||||||
|
|
||||||
- **class-chief-editor.php:** Wraps editorial review LLM call with Opik span.
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
- **Feature-flag default OFF.** Zero network traffic to Opik unless `prautoblogger_opik_enabled` option is true AND API credentials are defined in wp-config.php.
|
|
||||||
- **Image prompt generation instrumentation deferred.** File already at 314 LOC (over 300-line rule); can be instrumented in Wave 2 without modifying existing code.
|
|
||||||
- **Wave 2 holds eval harness.** CLI command for frozen-dataset regression testing with LLM-as-judge scoring (separate thread after v0.12.0 ships and is live-tested).
|
|
||||||
|
|
||||||
## [0.11.0] — 2026-04-23
|
## [0.11.0] — 2026-04-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
102
CONVENTIONS.md
102
CONVENTIONS.md
@@ -332,104 +332,4 @@ curl -s -X POST -H "Authorization: token $GH_TOKEN" \
|
|||||||
|
|
||||||
### When this changes
|
### When this changes
|
||||||
|
|
||||||
If the peptiderepo GitHub account is ever upgraded to Pro (or the repo goes public), replace this soft-enforcement section with a note pointing to the real branch protection rules in repo settings.
|
If the peptiderepo GitHub account is ever upgraded to Pro (or the repo goes public), replace this soft-enforcement section with a note pointing to the real branch protection rules in repo settings.
|
||||||
## Instrumenting a New LLM Call Site for Opik (v0.12.0+)
|
|
||||||
|
|
||||||
When you add a new LLM call (via `$llm->send_chat_completion()`), follow this pattern to capture it in Opik:
|
|
||||||
|
|
||||||
### 1. Get the trace context
|
|
||||||
|
|
||||||
At the start of your method, grab the per-request singleton:
|
|
||||||
|
|
||||||
```php
|
|
||||||
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Start the span
|
|
||||||
|
|
||||||
Before the LLM call, declare a span with metadata:
|
|
||||||
|
|
||||||
```php
|
|
||||||
$span_id = $ctx->start_span(
|
|
||||||
array(
|
|
||||||
'name' => 'my_llm_operation', // kebab-case, descriptive
|
|
||||||
'type' => 'llm',
|
|
||||||
'model' => $model, // e.g. 'gpt-4', from get_option()
|
|
||||||
'provider' => 'openrouter', // or the actual provider name
|
|
||||||
'input' => array( 'key' => 'value' ), // optional metadata (no full prompts!)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Make the LLM call (unchanged)
|
|
||||||
|
|
||||||
```php
|
|
||||||
$response = $this->llm->send_chat_completion(
|
|
||||||
array(
|
|
||||||
array( 'role' => 'system', 'content' => $system_prompt ),
|
|
||||||
array( 'role' => 'user', 'content' => $user_prompt ),
|
|
||||||
),
|
|
||||||
$model,
|
|
||||||
array( 'temperature' => 0.7, 'max_tokens' => 4000 )
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. End the span with output
|
|
||||||
|
|
||||||
After the response, record the outcome:
|
|
||||||
|
|
||||||
```php
|
|
||||||
$ctx->end_span(
|
|
||||||
$span_id,
|
|
||||||
array(
|
|
||||||
'output' => array( 'response_length' => strlen( $response['content'] ?? '' ) ),
|
|
||||||
'usage' => array(
|
|
||||||
'prompt_tokens' => $response['prompt_tokens'],
|
|
||||||
'completion_tokens' => $response['completion_tokens'],
|
|
||||||
'total_tokens' => $response['prompt_tokens'] + $response['completion_tokens'],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key rules
|
|
||||||
|
|
||||||
- **Never log full prompts.** Use prompt hashes or version tags in `input`. Full text blows payload size and leaks content policy.
|
|
||||||
- **Check the feature flag at call-site level?** No. The singleton is always available; Opik_Dispatcher checks the flag before dispatch.
|
|
||||||
- **Async dispatch?** Yes. Spans are queued; dispatch happens via `prautoblogger_opik_dispatch` cron (no blocking).
|
|
||||||
- **Cost logging?** Unchanged. Your `cost_tracker->log_api_call()` remains separate from Opik; Opik is observability-only.
|
|
||||||
|
|
||||||
### Example: minimal instrumentation
|
|
||||||
|
|
||||||
```php
|
|
||||||
public function my_operation( $input ) {
|
|
||||||
$model = get_option( 'my_model', 'default-model' );
|
|
||||||
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
|
|
||||||
|
|
||||||
$span_id = $ctx->start_span(
|
|
||||||
array(
|
|
||||||
'name' => 'my_operation',
|
|
||||||
'type' => 'llm',
|
|
||||||
'model' => $model,
|
|
||||||
'provider' => 'openrouter',
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$response = $this->llm->send_chat_completion( /* ... */ );
|
|
||||||
|
|
||||||
$ctx->end_span(
|
|
||||||
$span_id,
|
|
||||||
array(
|
|
||||||
'usage' => array(
|
|
||||||
'prompt_tokens' => $response['prompt_tokens'],
|
|
||||||
'completion_tokens' => $response['completion_tokens'],
|
|
||||||
'total_tokens' => $response['prompt_tokens'] + $response['completion_tokens'],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return $response['content'];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
That's it. No config, no feature-flag checks, no try/catch needed.
|
|
||||||
@@ -142,7 +142,6 @@
|
|||||||
color: var(--ab-text-muted, #64748b);
|
color: var(--ab-text-muted, #64748b);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
min-width: 90px; text-align: right; flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ab-mp-ctx {
|
.ab-mp-ctx {
|
||||||
@@ -151,7 +150,6 @@
|
|||||||
background: var(--ab-bg, #f8fafc);
|
background: var(--ab-bg, #f8fafc);
|
||||||
padding: 2px 6px; border-radius: 4px;
|
padding: 2px 6px; border-radius: 4px;
|
||||||
white-space: nowrap; min-width: 36px; text-align: center;
|
white-space: nowrap; min-width: 36px; text-align: center;
|
||||||
max-width: 200px; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Footer ─────────────────────────────────────────────────────────── */
|
/* ── Footer ─────────────────────────────────────────────────────────── */
|
||||||
@@ -206,59 +204,3 @@
|
|||||||
.ab-mp-row { padding: 10px 14px; }
|
.ab-mp-row { padding: 10px 14px; }
|
||||||
.ab-mp-row-meta { gap: 8px; }
|
.ab-mp-row-meta { gap: 8px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Column headers (sortable) ──────────────────────────────────────── */
|
|
||||||
.ab-mp-col-headers {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
padding: 5px 20px;
|
|
||||||
border-bottom: 1px solid var(--ab-border, #e2e8f0);
|
|
||||||
background: var(--ab-bg, #f8fafc);
|
|
||||||
gap: 12px; /* must match .ab-mp-row gap */
|
|
||||||
}
|
|
||||||
|
|
||||||
.ab-mp-col-headers-meta {
|
|
||||||
display: flex; align-items: center;
|
|
||||||
gap: 16px; /* must match .ab-mp-row-meta gap */
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ab-mp-col-header {
|
|
||||||
display: inline-flex; align-items: center;
|
|
||||||
font-size: 10px; font-weight: 700; text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--ab-text-muted, #64748b);
|
|
||||||
cursor: pointer; user-select: none;
|
|
||||||
background: none; border: none; padding: 3px 0;
|
|
||||||
white-space: nowrap; line-height: 1.2;
|
|
||||||
transition: color 0.1s ease;
|
|
||||||
}
|
|
||||||
.ab-mp-col-header:hover {
|
|
||||||
color: var(--ab-text, #1e293b);
|
|
||||||
}
|
|
||||||
.ab-mp-col-header.ab-mp-col-active {
|
|
||||||
color: var(--ab-primary, #4f46e5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* "Model" header spans the left flex-1 area */
|
|
||||||
.ab-mp-col-model {
|
|
||||||
flex: 1; min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* "Price" header width matches .ab-mp-price */
|
|
||||||
.ab-mp-col-price {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
min-width: 90px; justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* "Context" header width matches .ab-mp-ctx (min-width 36px + padding) */
|
|
||||||
.ab-mp-col-ctx {
|
|
||||||
min-width: 48px; justify-content: center; text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ab-mp-sort-arrow {
|
|
||||||
font-size: 9px; opacity: 0.5;
|
|
||||||
transition: opacity 0.1s ease;
|
|
||||||
}
|
|
||||||
.ab-mp-col-header.ab-mp-col-active .ab-mp-sort-arrow {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Model picker popup for PRAutoBlogger admin settings.
|
* Model picker popup for PRAutoBlogger admin settings.
|
||||||
*
|
*
|
||||||
* Fetches the OpenRouter model registry via AJAX, then renders a searchable,
|
* Fetches the OpenRouter model registry via AJAX, then renders a searchable
|
||||||
* sortable popup overlay when the user clicks a model-select trigger button.
|
* popup overlay when the user clicks a model-select trigger button. Shows
|
||||||
* Columns: Model, Price (in / out per 1M tokens), Context window.
|
* name, provider, input/output cost, and context window per model.
|
||||||
* Each column header is clickable to sort ascending/descending.
|
|
||||||
*
|
*
|
||||||
* @see admin/class-admin-page.php — Renders model_select fields and localizes config.
|
* @see admin/class-admin-page.php — Renders model_select fields and localizes config.
|
||||||
* @see services/class-open-router-model-registry.php — Server-side registry source.
|
* @see services/class-open-router-model-registry.php — Server-side registry source.
|
||||||
@@ -12,57 +11,54 @@
|
|||||||
(function ($) {
|
(function ($) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var config = window.prautobloggerAdmin || {};
|
var config = window.prautobloggerAdmin || {};
|
||||||
var cachedModels = null;
|
var cachedModels = null;
|
||||||
var fetchInProgress = null;
|
var fetchInProgress = null;
|
||||||
|
|
||||||
// Persists across open/close so the user's preferred sort is remembered.
|
|
||||||
var sortState = { col: 'name', dir: 'asc' };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch models from the server (cached after first call).
|
* Fetch models from the server (cached after first call).
|
||||||
*
|
*
|
||||||
* @param {string} capability Filter capability (e.g. 'text→text').
|
* @param {string} capability Filter capability (e.g. 'text→text').
|
||||||
* @return {jQuery.Deferred} Resolves with model array.
|
* @return {jQuery.Deferred} Resolves with model array.
|
||||||
*/
|
*/
|
||||||
function fetchModels( capability ) {
|
function fetchModels(capability) {
|
||||||
// Image models are embedded in the page config — no AJAX needed.
|
// Image models are embedded in the page config — no AJAX needed.
|
||||||
if ( capability === 'image_generation' ) {
|
if (capability === 'image_generation') {
|
||||||
var imgModels = config.imageModels || [];
|
var imgModels = config.imageModels || [];
|
||||||
return $.Deferred().resolve( imgModels );
|
return $.Deferred().resolve(imgModels);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( cachedModels ) {
|
if (cachedModels) {
|
||||||
return $.Deferred().resolve( filterModels( cachedModels, capability ) );
|
return $.Deferred().resolve(filterModels(cachedModels, capability));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( fetchInProgress ) {
|
if (fetchInProgress) {
|
||||||
return fetchInProgress.then( function () {
|
return fetchInProgress.then(function () {
|
||||||
return filterModels( cachedModels, capability );
|
return filterModels(cachedModels, capability);
|
||||||
} );
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchInProgress = $.ajax( {
|
fetchInProgress = $.ajax({
|
||||||
url: config.ajaxUrl,
|
url: config.ajaxUrl,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: {
|
data: {
|
||||||
action: 'prautoblogger_get_models',
|
action: 'prautoblogger_get_models',
|
||||||
nonce: config.modelsNonce
|
nonce: config.modelsNonce
|
||||||
},
|
},
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
} ).then( function ( response ) {
|
}).then(function (response) {
|
||||||
fetchInProgress = null;
|
fetchInProgress = null;
|
||||||
if ( response.success && Array.isArray( response.data ) ) {
|
if (response.success && Array.isArray(response.data)) {
|
||||||
cachedModels = response.data;
|
cachedModels = response.data;
|
||||||
return filterModels( cachedModels, capability );
|
return filterModels(cachedModels, capability);
|
||||||
}
|
}
|
||||||
cachedModels = [];
|
cachedModels = [];
|
||||||
return [];
|
return [];
|
||||||
}, function () {
|
}, function () {
|
||||||
fetchInProgress = null;
|
fetchInProgress = null;
|
||||||
cachedModels = [];
|
cachedModels = [];
|
||||||
return [];
|
return [];
|
||||||
} );
|
});
|
||||||
|
|
||||||
return fetchInProgress;
|
return fetchInProgress;
|
||||||
}
|
}
|
||||||
@@ -74,49 +70,11 @@
|
|||||||
* @param {string} capability Capability string to match.
|
* @param {string} capability Capability string to match.
|
||||||
* @return {Array} Filtered models.
|
* @return {Array} Filtered models.
|
||||||
*/
|
*/
|
||||||
function filterModels( models, capability ) {
|
function filterModels(models, capability) {
|
||||||
if ( ! capability ) return models;
|
if (!capability) return models;
|
||||||
return models.filter( function ( m ) {
|
return models.filter(function (m) {
|
||||||
return m.capabilities && m.capabilities.indexOf( capability ) !== -1;
|
return m.capabilities && m.capabilities.indexOf(capability) !== -1;
|
||||||
} );
|
});
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sort a model array according to the current sortState.
|
|
||||||
* Deprecated models always sink to the bottom regardless of column.
|
|
||||||
*
|
|
||||||
* @param {Array} arr Model array to sort (not mutated — returns a new array).
|
|
||||||
* @return {Array} Sorted copy.
|
|
||||||
*/
|
|
||||||
function applySort( arr ) {
|
|
||||||
var col = sortState.col;
|
|
||||||
var dir = sortState.dir;
|
|
||||||
|
|
||||||
return arr.slice().sort( function ( a, b ) {
|
|
||||||
// Deprecated always last.
|
|
||||||
if ( a.deprecated !== b.deprecated ) {
|
|
||||||
return a.deprecated ? 1 : -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
var valA, valB;
|
|
||||||
|
|
||||||
if ( col === 'price' ) {
|
|
||||||
// Image models use cost_per_image; text models use input_price_per_m.
|
|
||||||
valA = ( a.cost_per_image !== undefined ) ? a.cost_per_image : ( a.input_price_per_m || 0 );
|
|
||||||
valB = ( b.cost_per_image !== undefined ) ? b.cost_per_image : ( b.input_price_per_m || 0 );
|
|
||||||
} else if ( col === 'context' ) {
|
|
||||||
valA = a.context_length || 0;
|
|
||||||
valB = b.context_length || 0;
|
|
||||||
} else {
|
|
||||||
// Default: sort by model name.
|
|
||||||
valA = ( a.name || '' ).toLowerCase();
|
|
||||||
valB = ( b.name || '' ).toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( valA < valB ) return dir === 'asc' ? -1 : 1;
|
|
||||||
if ( valA > valB ) return dir === 'asc' ? 1 : -1;
|
|
||||||
return 0;
|
|
||||||
} );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,11 +83,11 @@
|
|||||||
* @param {number} price Price per 1M tokens.
|
* @param {number} price Price per 1M tokens.
|
||||||
* @return {string} Formatted string like "$0.25" or "Free".
|
* @return {string} Formatted string like "$0.25" or "Free".
|
||||||
*/
|
*/
|
||||||
function formatPrice( price ) {
|
function formatPrice(price) {
|
||||||
if ( ! price || price <= 0 ) return 'Free';
|
if (!price || price <= 0) return 'Free';
|
||||||
if ( price < 0.01 ) return '$' + price.toFixed( 4 );
|
if (price < 0.01) return '$' + price.toFixed(4);
|
||||||
if ( price < 1 ) return '$' + price.toFixed( 2 );
|
if (price < 1) return '$' + price.toFixed(2);
|
||||||
return '$' + price.toFixed( 2 );
|
return '$' + price.toFixed(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -138,10 +96,10 @@
|
|||||||
* @param {number} ctx Context length in tokens.
|
* @param {number} ctx Context length in tokens.
|
||||||
* @return {string} Formatted string like "128K" or "1M".
|
* @return {string} Formatted string like "128K" or "1M".
|
||||||
*/
|
*/
|
||||||
function formatContext( ctx ) {
|
function formatContext(ctx) {
|
||||||
if ( ! ctx ) return '—';
|
if (!ctx) return '—';
|
||||||
if ( ctx >= 1000000 ) return ( ctx / 1000000 ).toFixed( 0 ) + 'M';
|
if (ctx >= 1000000) return (ctx / 1000000).toFixed(0) + 'M';
|
||||||
if ( ctx >= 1000 ) return Math.round( ctx / 1000 ) + 'K';
|
if (ctx >= 1000) return Math.round(ctx / 1000) + 'K';
|
||||||
return ctx.toString();
|
return ctx.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,47 +109,45 @@
|
|||||||
* @param {Object} model Normalized model record.
|
* @param {Object} model Normalized model record.
|
||||||
* @return {string} HTML string.
|
* @return {string} HTML string.
|
||||||
*/
|
*/
|
||||||
function renderModelRow( model ) {
|
function renderModelRow(model) {
|
||||||
var deprecated = model.deprecated ? ' ab-mp-deprecated' : '';
|
var deprecated = model.deprecated ? ' ab-mp-deprecated' : '';
|
||||||
var isImageModel = model.cost_per_image !== undefined && model.cost_per_image > 0;
|
var isImageModel = model.cost_per_image !== undefined && model.cost_per_image > 0;
|
||||||
var priceHtml, priceTitle, metaRight;
|
var priceHtml, priceTitle, metaRight;
|
||||||
|
|
||||||
if ( isImageModel ) {
|
if (isImageModel) {
|
||||||
priceHtml = '$' + model.cost_per_image.toFixed( 4 ) + '/image';
|
priceHtml = '$' + model.cost_per_image.toFixed(4) + '/image';
|
||||||
priceTitle = 'Cost per generated image';
|
priceTitle = 'Cost per generated image';
|
||||||
metaRight = model.description
|
metaRight = model.description ? '<span class="ab-mp-ctx" title="Details">' + escHtml(model.description) + '</span>' : '';
|
||||||
? '<span class="ab-mp-ctx" title="Details">' + escHtml( model.description ) + '</span>'
|
|
||||||
: '';
|
|
||||||
} else {
|
} else {
|
||||||
priceHtml = formatPrice( model.input_price_per_m ) + ' / ' + formatPrice( model.output_price_per_m );
|
priceHtml = formatPrice(model.input_price_per_m) + ' / ' + formatPrice(model.output_price_per_m);
|
||||||
priceTitle = 'Input / Output per 1M tokens';
|
priceTitle = 'Input / Output per 1M tokens';
|
||||||
metaRight = '<span class="ab-mp-ctx" title="Context window">' + formatContext( model.context_length ) + '</span>';
|
metaRight = '<span class="ab-mp-ctx" title="Context window">' + formatContext(model.context_length) + '</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
'<div class="ab-mp-row' + deprecated + '" data-model-id="' + escAttr( model.id ) + '">' +
|
'<div class="ab-mp-row' + deprecated + '" data-model-id="' + escAttr(model.id) + '">' +
|
||||||
'<div class="ab-mp-row-main">' +
|
'<div class="ab-mp-row-main">' +
|
||||||
'<span class="ab-mp-name">' + escHtml( model.name ) + '</span>' +
|
'<span class="ab-mp-name">' + escHtml(model.name) + '</span>' +
|
||||||
'<span class="ab-mp-provider">' + escHtml( model.provider ) + '</span>' +
|
'<span class="ab-mp-provider">' + escHtml(model.provider) + '</span>' +
|
||||||
( model.deprecated ? '<span class="ab-mp-dep-badge">deprecated</span>' : '' ) +
|
(model.deprecated ? '<span class="ab-mp-dep-badge">deprecated</span>' : '') +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="ab-mp-row-meta">' +
|
'<div class="ab-mp-row-meta">' +
|
||||||
metaRight +
|
|
||||||
'<span class="ab-mp-price" title="' + priceTitle + '">' + priceHtml + '</span>' +
|
'<span class="ab-mp-price" title="' + priceTitle + '">' + priceHtml + '</span>' +
|
||||||
|
metaRight +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>'
|
'</div>'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Minimal HTML escaping for display. */
|
/** Minimal HTML escaping for display. */
|
||||||
function escHtml( str ) {
|
function escHtml(str) {
|
||||||
if ( ! str ) return '';
|
if (!str) return '';
|
||||||
return str.replace( /&/g, '&' ).replace( /</g, '<' ).replace( />/g, '>' );
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Escape a string for use in an HTML attribute. */
|
/** Escape a string for use in an HTML attribute. */
|
||||||
function escAttr( str ) {
|
function escAttr(str) {
|
||||||
return escHtml( str ).replace( /"/g, '"' );
|
return escHtml(str).replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -199,16 +155,16 @@
|
|||||||
*
|
*
|
||||||
* @param {jQuery} $trigger The .ab-mp-trigger button that was clicked.
|
* @param {jQuery} $trigger The .ab-mp-trigger button that was clicked.
|
||||||
*/
|
*/
|
||||||
function openPicker( $trigger ) {
|
function openPicker($trigger) {
|
||||||
var fieldId = $trigger.data( 'field-id' );
|
var fieldId = $trigger.data('field-id');
|
||||||
var capability = $trigger.data( 'capability' ) || '';
|
var capability = $trigger.data('capability') || '';
|
||||||
|
|
||||||
|
// Build overlay + popup structure.
|
||||||
var isImageCap = capability === 'image_generation';
|
var isImageCap = capability === 'image_generation';
|
||||||
var priceLabel = isImageCap ? 'Cost per generated image' : 'Prices per 1M tokens (in / out)';
|
var priceLabel = isImageCap ? 'Cost per generated image' : 'Prices per 1M tokens (in / out)';
|
||||||
|
|
||||||
// ── Build overlay + popup ───────────────────────────────────────
|
var $overlay = $('<div class="ab-mp-overlay"></div>');
|
||||||
var $overlay = $( '<div class="ab-mp-overlay"></div>' );
|
var $popup = $(
|
||||||
var $popup = $(
|
|
||||||
'<div class="ab-mp-popup">' +
|
'<div class="ab-mp-popup">' +
|
||||||
'<div class="ab-mp-header">' +
|
'<div class="ab-mp-header">' +
|
||||||
'<span class="ab-mp-title">Select Model</span>' +
|
'<span class="ab-mp-title">Select Model</span>' +
|
||||||
@@ -217,13 +173,6 @@
|
|||||||
'<div class="ab-mp-search-wrap">' +
|
'<div class="ab-mp-search-wrap">' +
|
||||||
'<input type="text" class="ab-mp-search" placeholder="Search models…" autocomplete="off" />' +
|
'<input type="text" class="ab-mp-search" placeholder="Search models…" autocomplete="off" />' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="ab-mp-col-headers">' +
|
|
||||||
'<button type="button" class="ab-mp-col-header ab-mp-col-model" data-col="name">Model <span class="ab-mp-sort-arrow"></span></button>' +
|
|
||||||
'<div class="ab-mp-col-headers-meta">' +
|
|
||||||
'<button type="button" class="ab-mp-col-header ab-mp-col-ctx" data-col="context">Context <span class="ab-mp-sort-arrow"></span></button>' +
|
|
||||||
'<button type="button" class="ab-mp-col-header ab-mp-col-price" data-col="price">Price (in / out) <span class="ab-mp-sort-arrow"></span></button>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="ab-mp-list"><div class="ab-mp-loading">Loading models…</div></div>' +
|
'<div class="ab-mp-list"><div class="ab-mp-loading">Loading models…</div></div>' +
|
||||||
'<div class="ab-mp-footer">' +
|
'<div class="ab-mp-footer">' +
|
||||||
'<span class="ab-mp-count"></span>' +
|
'<span class="ab-mp-count"></span>' +
|
||||||
@@ -232,132 +181,105 @@
|
|||||||
'</div>'
|
'</div>'
|
||||||
);
|
);
|
||||||
|
|
||||||
$overlay.append( $popup );
|
$overlay.append($popup);
|
||||||
$( 'body' ).append( $overlay );
|
$('body').append($overlay);
|
||||||
|
|
||||||
var $search = $popup.find( '.ab-mp-search' );
|
var $search = $popup.find('.ab-mp-search');
|
||||||
var $list = $popup.find( '.ab-mp-list' );
|
var $list = $popup.find('.ab-mp-list');
|
||||||
var $count = $popup.find( '.ab-mp-count' );
|
var $count = $popup.find('.ab-mp-count');
|
||||||
var $colHeaders = $popup.find( '.ab-mp-col-headers' );
|
var allModels = [];
|
||||||
var allModels = [];
|
|
||||||
var currentQuery = '';
|
|
||||||
|
|
||||||
// Focus search after render.
|
// Focus search after render.
|
||||||
setTimeout( function () { $search.focus(); }, 50 );
|
setTimeout(function () { $search.focus(); }, 50);
|
||||||
|
|
||||||
// ── Sort header indicators ──────────────────────────────────────
|
// Fetch and render model list.
|
||||||
/** Update active-column highlight and sort arrow glyphs. */
|
fetchModels(capability).then(function (models) {
|
||||||
function updateSortHeaders() {
|
allModels = models;
|
||||||
$colHeaders.find( '.ab-mp-col-header' ).each( function () {
|
renderList(models, '');
|
||||||
var col = $( this ).data( 'col' );
|
});
|
||||||
var $arrow = $( this ).find( '.ab-mp-sort-arrow' );
|
|
||||||
if ( col === sortState.col ) {
|
|
||||||
$( this ).addClass( 'ab-mp-col-active' );
|
|
||||||
$arrow.text( sortState.dir === 'asc' ? ' ▲' : ' ▼' );
|
|
||||||
} else {
|
|
||||||
$( this ).removeClass( 'ab-mp-col-active' );
|
|
||||||
$arrow.text( ' ⇅' );
|
|
||||||
}
|
|
||||||
} );
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Render ──────────────────────────────────────────────────────
|
/** Render filtered model list. */
|
||||||
/** Render filtered + sorted model list. */
|
function renderList(models, query) {
|
||||||
function renderList( models, query ) {
|
|
||||||
var filtered = models;
|
var filtered = models;
|
||||||
if ( query ) {
|
if (query) {
|
||||||
var q = query.toLowerCase();
|
var q = query.toLowerCase();
|
||||||
filtered = models.filter( function ( m ) {
|
filtered = models.filter(function (m) {
|
||||||
return ( m.name && m.name.toLowerCase().indexOf( q ) !== -1 ) ||
|
return (m.name && m.name.toLowerCase().indexOf(q) !== -1) ||
|
||||||
( m.id && m.id.toLowerCase().indexOf( q ) !== -1 ) ||
|
(m.id && m.id.toLowerCase().indexOf(q) !== -1) ||
|
||||||
( m.provider && m.provider.toLowerCase().indexOf( q ) !== -1 );
|
(m.provider && m.provider.toLowerCase().indexOf(q) !== -1);
|
||||||
} );
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered = applySort( filtered );
|
// Sort: non-deprecated first, then by provider + name.
|
||||||
|
filtered.sort(function (a, b) {
|
||||||
|
if (a.deprecated !== b.deprecated) return a.deprecated ? 1 : -1;
|
||||||
|
var pa = (a.provider || '').toLowerCase();
|
||||||
|
var pb = (b.provider || '').toLowerCase();
|
||||||
|
if (pa !== pb) return pa < pb ? -1 : 1;
|
||||||
|
return (a.name || '').toLowerCase().localeCompare((b.name || '').toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
if ( filtered.length === 0 ) {
|
if (filtered.length === 0) {
|
||||||
$list.html( '<div class="ab-mp-empty">No models found.</div>' );
|
$list.html('<div class="ab-mp-empty">No models found.</div>');
|
||||||
} else {
|
} else {
|
||||||
var html = '';
|
var html = '';
|
||||||
for ( var i = 0; i < filtered.length; i++ ) {
|
for (var i = 0; i < filtered.length; i++) {
|
||||||
html += renderModelRow( filtered[ i ] );
|
html += renderModelRow(filtered[i]);
|
||||||
}
|
}
|
||||||
$list.html( html );
|
$list.html(html);
|
||||||
}
|
}
|
||||||
$count.text( filtered.length + ' model' + ( filtered.length !== 1 ? 's' : '' ) );
|
$count.text(filtered.length + ' model' + (filtered.length !== 1 ? 's' : ''));
|
||||||
updateSortHeaders();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial render after data loads.
|
// Search input handler (debounced).
|
||||||
fetchModels( capability ).then( function ( models ) {
|
|
||||||
allModels = models;
|
|
||||||
renderList( allModels, currentQuery );
|
|
||||||
} );
|
|
||||||
|
|
||||||
// ── Column header sort clicks ───────────────────────────────────
|
|
||||||
$colHeaders.on( 'click', '.ab-mp-col-header', function () {
|
|
||||||
var col = $( this ).data( 'col' );
|
|
||||||
if ( sortState.col === col ) {
|
|
||||||
sortState.dir = sortState.dir === 'asc' ? 'desc' : 'asc';
|
|
||||||
} else {
|
|
||||||
sortState.col = col;
|
|
||||||
sortState.dir = 'asc';
|
|
||||||
}
|
|
||||||
renderList( allModels, currentQuery );
|
|
||||||
} );
|
|
||||||
|
|
||||||
// ── Search (debounced) ──────────────────────────────────────────
|
|
||||||
var searchTimer = null;
|
var searchTimer = null;
|
||||||
$search.on( 'input', function () {
|
$search.on('input', function () {
|
||||||
currentQuery = $( this ).val();
|
var q = $(this).val();
|
||||||
clearTimeout( searchTimer );
|
clearTimeout(searchTimer);
|
||||||
searchTimer = setTimeout( function () {
|
searchTimer = setTimeout(function () {
|
||||||
renderList( allModels, currentQuery );
|
renderList(allModels, q);
|
||||||
}, 150 );
|
}, 150);
|
||||||
} );
|
});
|
||||||
|
|
||||||
// ── Select a model ──────────────────────────────────────────────
|
// Select a model.
|
||||||
$list.on( 'click', '.ab-mp-row', function () {
|
$list.on('click', '.ab-mp-row', function () {
|
||||||
var id = $( this ).data( 'model-id' );
|
var id = $(this).data('model-id');
|
||||||
var model = null;
|
var model = allModels.find(function (m) { return m.id === id; });
|
||||||
for ( var i = 0; i < allModels.length; i++ ) {
|
if (!model) return;
|
||||||
if ( allModels[ i ].id === id ) { model = allModels[ i ]; break; }
|
|
||||||
}
|
|
||||||
if ( ! model ) return;
|
|
||||||
|
|
||||||
$( '#' + fieldId ).val( model.id );
|
// Update hidden input + display.
|
||||||
$trigger.find( '.ab-mp-display-name' ).text( model.name || model.id );
|
$('#' + fieldId).val(model.id);
|
||||||
|
$trigger.find('.ab-mp-display-name').text(model.name || model.id);
|
||||||
|
|
||||||
var isImageModel = model.cost_per_image !== undefined && model.cost_per_image > 0;
|
var isImageModel = model.cost_per_image !== undefined && model.cost_per_image > 0;
|
||||||
var priceText = isImageModel
|
var priceText = isImageModel
|
||||||
? '$' + model.cost_per_image.toFixed( 4 ) + '/image'
|
? '$' + model.cost_per_image.toFixed(4) + '/image'
|
||||||
: formatPrice( model.input_price_per_m ) + ' / ' + formatPrice( model.output_price_per_m );
|
: formatPrice(model.input_price_per_m) + ' / ' + formatPrice(model.output_price_per_m);
|
||||||
$trigger.find( '.ab-mp-display-price' ).text( priceText );
|
$trigger.find('.ab-mp-display-price').text(priceText);
|
||||||
closePicker();
|
closePicker();
|
||||||
} );
|
});
|
||||||
|
|
||||||
// ── Close ───────────────────────────────────────────────────────
|
// Close handlers.
|
||||||
function closePicker() {
|
function closePicker() {
|
||||||
$overlay.remove();
|
$overlay.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
$popup.find( '.ab-mp-close' ).on( 'click', closePicker );
|
$popup.find('.ab-mp-close').on('click', closePicker);
|
||||||
$overlay.on( 'click', function ( e ) {
|
$overlay.on('click', function (e) {
|
||||||
if ( $( e.target ).is( '.ab-mp-overlay' ) ) closePicker();
|
if ($(e.target).is('.ab-mp-overlay')) closePicker();
|
||||||
} );
|
});
|
||||||
$( document ).on( 'keydown.abModelPicker', function ( e ) {
|
$(document).on('keydown.abModelPicker', function (e) {
|
||||||
if ( e.key === 'Escape' ) {
|
if (e.key === 'Escape') {
|
||||||
closePicker();
|
closePicker();
|
||||||
$( document ).off( 'keydown.abModelPicker' );
|
$(document).off('keydown.abModelPicker');
|
||||||
}
|
}
|
||||||
} );
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bind click on all model picker triggers.
|
// Bind click on all model picker triggers.
|
||||||
$( document ).on( 'click', '.ab-mp-trigger', function ( e ) {
|
$(document).on('click', '.ab-mp-trigger', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openPicker( $( this ) );
|
openPicker($(this));
|
||||||
} );
|
});
|
||||||
|
|
||||||
} )( jQuery );
|
})(jQuery);
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"id": "bpc157-healing",
|
|
||||||
"topic": "BPC-157 healing protocols for tendons and gut lining",
|
|
||||||
"expected_keywords": ["BPC-157", "tendon", "gut", "healing"],
|
|
||||||
"min_words": 700,
|
|
||||||
"style": "comic"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "tb500-muscle-recovery",
|
|
||||||
"topic": "TB-500 for accelerated muscle recovery and repair",
|
|
||||||
"expected_keywords": ["TB-500", "muscle", "recovery", "growth"],
|
|
||||||
"min_words": 700,
|
|
||||||
"style": "comic"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ghk-cu-skin-collagen",
|
|
||||||
"topic": "GHK-Cu peptide for skin collagen synthesis and anti-aging",
|
|
||||||
"expected_keywords": ["GHK-Cu", "collagen", "skin", "aging"],
|
|
||||||
"min_words": 700,
|
|
||||||
"style": "comic"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "semaglutide-glp1-weight",
|
|
||||||
"topic": "GLP-1 receptor agonists like Semaglutide for metabolic health",
|
|
||||||
"expected_keywords": ["Semaglutide", "GLP-1", "weight", "metabolism"],
|
|
||||||
"min_words": 700,
|
|
||||||
"style": "comic"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ipamorelin-growth-hormone",
|
|
||||||
"topic": "Ipamorelin for natural growth hormone stimulation and recovery",
|
|
||||||
"expected_keywords": ["Ipamorelin", "growth hormone", "HGH", "secretagogue"],
|
|
||||||
"min_words": 700,
|
|
||||||
"style": "comic"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "hexarelin-strength-gains",
|
|
||||||
"topic": "Hexarelin peptide for lean muscle and strength improvement",
|
|
||||||
"expected_keywords": ["Hexarelin", "muscle", "strength", "lean"],
|
|
||||||
"min_words": 700,
|
|
||||||
"style": "comic"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "semax-cognitive-enhancement",
|
|
||||||
"topic": "Semax nootropic peptide for cognitive function and focus",
|
|
||||||
"expected_keywords": ["Semax", "cognition", "focus", "neuroprotection"],
|
|
||||||
"min_words": 700,
|
|
||||||
"style": "comic"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "selank-anxiety-stress",
|
|
||||||
"topic": "Selank peptide for anxiety reduction and stress management",
|
|
||||||
"expected_keywords": ["Selank", "anxiety", "stress", "mood"],
|
|
||||||
"min_words": 700,
|
|
||||||
"style": "comic"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "oxytocin-social-bonding",
|
|
||||||
"topic": "Oxytocin peptide hormone and its role in social connection",
|
|
||||||
"expected_keywords": ["Oxytocin", "social", "bonding", "neuropeptide"],
|
|
||||||
"min_words": 700,
|
|
||||||
"style": "comic"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "kisspeptin-fertility-hormone",
|
|
||||||
"topic": "Kisspeptin's role in reproductive health and hormone signaling",
|
|
||||||
"expected_keywords": ["Kisspeptin", "fertility", "hormone", "reproduction"],
|
|
||||||
"min_words": 700,
|
|
||||||
"style": "comic"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "melanotan-tanning-peptide",
|
|
||||||
"topic": "Melanotan peptide for pigmentation and UV protection",
|
|
||||||
"expected_keywords": ["Melanotan", "melanin", "tanning", "skin"],
|
|
||||||
"min_words": 700,
|
|
||||||
"style": "comic"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "cerebrolysin-neuroprotection",
|
|
||||||
"topic": "Cerebrolysin for brain health and neuroprotection",
|
|
||||||
"expected_keywords": ["Cerebrolysin", "brain", "neuroprotection", "cognition"],
|
|
||||||
"min_words": 700,
|
|
||||||
"style": "comic"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "epithalon-telomerase-aging",
|
|
||||||
"topic": "Epithalon peptide and telomerase activation for longevity",
|
|
||||||
"expected_keywords": ["Epithalon", "telomerase", "aging", "longevity"],
|
|
||||||
"min_words": 700,
|
|
||||||
"style": "comic"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "thymosin-immune-function",
|
|
||||||
"topic": "Thymosin alpha-1 for immune system enhancement",
|
|
||||||
"expected_keywords": ["Thymosin", "immune", "T-cells", "immunity"],
|
|
||||||
"min_words": 700,
|
|
||||||
"style": "comic"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "igf1-muscle-growth-aging",
|
|
||||||
"topic": "IGF-1 peptide for muscle growth, recovery, and anti-aging",
|
|
||||||
"expected_keywords": ["IGF-1", "muscle", "growth", "aging"],
|
|
||||||
"min_words": 700,
|
|
||||||
"style": "comic"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "mechano-growth-factor",
|
|
||||||
"topic": "MGF (Mechano Growth Factor) for muscle hypertrophy",
|
|
||||||
"expected_keywords": ["MGF", "mechano", "muscle", "hypertrophy"],
|
|
||||||
"min_words": 700,
|
|
||||||
"style": "comic"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ll37-antimicrobial-peptide",
|
|
||||||
"topic": "LL-37 antimicrobial peptide for immune and skin health",
|
|
||||||
"expected_keywords": ["LL-37", "antimicrobial", "immune", "peptide"],
|
|
||||||
"min_words": 700,
|
|
||||||
"style": "comic"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "lactoferrin-iron-immunity",
|
|
||||||
"topic": "Lactoferrin peptide for iron absorption and immune support",
|
|
||||||
"expected_keywords": ["Lactoferrin", "iron", "immune", "absorption"],
|
|
||||||
"min_words": 700,
|
|
||||||
"style": "comic"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "collagen-peptides-joints",
|
|
||||||
"topic": "Collagen peptides for joint health and connective tissue repair",
|
|
||||||
"expected_keywords": ["Collagen", "peptides", "joints", "connective tissue"],
|
|
||||||
"min_words": 700,
|
|
||||||
"style": "comic"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ckgp-cognitive-longevity",
|
|
||||||
"topic": "Cognitive peptides and nootropic stacks for brain longevity",
|
|
||||||
"expected_keywords": ["peptide", "cognitive", "nootropic", "brain"],
|
|
||||||
"min_words": 700,
|
|
||||||
"style": "comic"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -330,11 +330,6 @@ class PRAutoBlogger_Admin_Page {
|
|||||||
if ( 'prautoblogger_image_model' === $option_name ) {
|
if ( 'prautoblogger_image_model' === $option_name ) {
|
||||||
return $this->sanitize_image_model( (string) $value );
|
return $this->sanitize_image_model( (string) $value );
|
||||||
}
|
}
|
||||||
if ( 'prautoblogger_image_style_template' === $option_name ) {
|
|
||||||
// Multi-line template: validation + textarea-safe sanitisation
|
|
||||||
// (incl. the single-token check, brief A5) lives in the filler.
|
|
||||||
return PRAutoBlogger_Image_Template_Filler::sanitize_for_save( (string) $value );
|
|
||||||
}
|
|
||||||
return sanitize_text_field( (string) $value );
|
return sanitize_text_field( (string) $value );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,25 +6,20 @@ declare(strict_types=1);
|
|||||||
*
|
*
|
||||||
* Static registry of available image generation models.
|
* Static registry of available image generation models.
|
||||||
*
|
*
|
||||||
* What: Returns a merged list of Runware models (from live catalog sync) and
|
* What: Returns a hardcoded list of image models for the admin model picker.
|
||||||
* OpenRouter models (static list). No API discovery for OpenRouter — updated
|
* No API discovery — updated manually when providers add/remove models.
|
||||||
* manually when new models are available.
|
|
||||||
* As of 2026-04-26, registry includes 15 Runware models (live) + 6 OpenRouter
|
|
||||||
* models (static), ordered cheapest-to-most-expensive within each provider.
|
|
||||||
* Who calls it: PRAutoBlogger_Admin_Page (model picker UI + save-time provider
|
* Who calls it: PRAutoBlogger_Admin_Page (model picker UI + save-time provider
|
||||||
* derivation) and PRAutoBlogger_Image_Pipeline (provider lookup).
|
* derivation) and PRAutoBlogger_Image_Pipeline (provider lookup).
|
||||||
* Dependencies: PRAutoBlogger_Runware_Model_Catalog (live Runware models),
|
* Dependencies: None.
|
||||||
* hardcoded fallback lists for resilience.
|
|
||||||
*
|
*
|
||||||
* @see admin/class-settings-fields-extended.php — Image settings reference this registry.
|
* @see admin/class-settings-fields-extended.php — Image settings reference this registry.
|
||||||
* @see core/class-image-pipeline.php — Derives provider from picked model.
|
* @see core/class-image-pipeline.php — Derives provider from picked model.
|
||||||
* @see providers/class-runware-image-provider.php — Runware FLUX backend.
|
* @see providers/class-runware-image-provider.php — Runware FLUX backend.
|
||||||
* @see providers/class-runware-model-catalog.php — Live model sync and caching.
|
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger_Image_Model_Registry {
|
class PRAutoBlogger_Image_Model_Registry {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all available image generation models (Runware live + OpenRouter static).
|
* Get all available image generation models.
|
||||||
*
|
*
|
||||||
* Each entry contains:
|
* Each entry contains:
|
||||||
* - id: Model identifier used in API calls.
|
* - id: Model identifier used in API calls.
|
||||||
@@ -40,10 +35,64 @@ class PRAutoBlogger_Image_Model_Registry {
|
|||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
public static function get_models(): array {
|
public static function get_models(): array {
|
||||||
$catalog = new PRAutoBlogger_Runware_Model_Catalog();
|
return array(
|
||||||
$runware_models = $catalog->get_models();
|
array(
|
||||||
$openrouter_models = self::get_openrouter_models();
|
'id' => 'runware:100@1',
|
||||||
return array_merge( $runware_models, $openrouter_models );
|
'name' => 'FLUX.1 schnell (Runware)',
|
||||||
|
'provider' => 'runware',
|
||||||
|
'cost_per_image' => 0.0006,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'Default. Fast, very cheap, comic-friendly fidelity.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'runware:101@1',
|
||||||
|
'name' => 'FLUX.1 dev (Runware)',
|
||||||
|
'provider' => 'runware',
|
||||||
|
'cost_per_image' => 0.02,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'Higher fidelity FLUX. ~30x schnell cost.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'google/gemini-2.5-flash-image',
|
||||||
|
'name' => 'Gemini 2.5 Flash Image (Nano Banana)',
|
||||||
|
'provider' => 'openrouter',
|
||||||
|
'cost_per_image' => 0.005,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'Mid-tier quality, proven.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'google/gemini-3.1-flash-image-preview',
|
||||||
|
'name' => 'Gemini 3.1 Flash Image (Nano Banana 2)',
|
||||||
|
'provider' => 'openrouter',
|
||||||
|
'cost_per_image' => 0.008,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'Latest Google. Better quality.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'google/gemini-3-pro-image-preview',
|
||||||
|
'name' => 'Gemini 3 Pro Image (Nano Banana Pro)',
|
||||||
|
'provider' => 'openrouter',
|
||||||
|
'cost_per_image' => 0.03,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'Highest quality Google. Premium.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'openai/gpt-5-image-mini',
|
||||||
|
'name' => 'GPT-5 Image Mini',
|
||||||
|
'provider' => 'openrouter',
|
||||||
|
'cost_per_image' => 0.02,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'OpenAI budget image model.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'id' => 'openai/gpt-5-image',
|
||||||
|
'name' => 'GPT-5 Image',
|
||||||
|
'provider' => 'openrouter',
|
||||||
|
'cost_per_image' => 0.08,
|
||||||
|
'capabilities' => array( 'image_generation' ),
|
||||||
|
'description' => __( 'OpenAI premium. Photorealistic.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,204 +110,4 @@ class PRAutoBlogger_Image_Model_Registry {
|
|||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hardcoded Runware model list used as fallback when the live catalog
|
|
||||||
* sync fails or API is unreachable. Kept in sync with actual Runware
|
|
||||||
* pricing page (runware.ai/pricing). This is the source of truth for
|
|
||||||
* pricing data when the Runware API doesn't expose cost information.
|
|
||||||
*
|
|
||||||
* Last verified: 2026-04-26. To update: check runware.ai/pricing for new
|
|
||||||
* text-to-image models and their costs.
|
|
||||||
*
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public static function get_runware_fallback_models(): array {
|
|
||||||
return array(
|
|
||||||
array(
|
|
||||||
'id' => 'runware:100@1',
|
|
||||||
'name' => 'FLUX.1 schnell (Runware)',
|
|
||||||
'provider' => 'runware',
|
|
||||||
'cost_per_image' => 0.0006,
|
|
||||||
'capabilities' => array( 'image_generation' ),
|
|
||||||
'description' => __( 'Default. Fast, very cheap, comic-friendly fidelity.', 'prautoblogger' ),
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'id' => 'runware:twinflow-z-image-turbo@0',
|
|
||||||
'name' => 'TwinFlow Z-Image-Turbo (Runware)',
|
|
||||||
'provider' => 'runware',
|
|
||||||
'cost_per_image' => 0.0006,
|
|
||||||
'capabilities' => array( 'image_generation' ),
|
|
||||||
'description' => __( 'Fast text-to-image, same cost as schnell.', 'prautoblogger' ),
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'id' => 'runware:400@2',
|
|
||||||
'name' => 'FLUX.2 klein 9B (Runware)',
|
|
||||||
'provider' => 'runware',
|
|
||||||
'cost_per_image' => 0.00078,
|
|
||||||
'capabilities' => array( 'image_generation' ),
|
|
||||||
'description' => __( 'FLUX.2 compact 9B. Stronger than schnell at similar cost.', 'prautoblogger' ),
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'id' => 'runware:400@4',
|
|
||||||
'name' => 'FLUX.2 klein 4B (Runware)',
|
|
||||||
'provider' => 'runware',
|
|
||||||
'cost_per_image' => 0.0006,
|
|
||||||
'capabilities' => array( 'image_generation' ),
|
|
||||||
'description' => __( 'FLUX.2 compact 4B. Newer architecture at schnell price.', 'prautoblogger' ),
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'id' => 'runware:5@1',
|
|
||||||
'name' => 'Stable Diffusion 3 (Runware)',
|
|
||||||
'provider' => 'runware',
|
|
||||||
'cost_per_image' => 0.0013,
|
|
||||||
'capabilities' => array( 'image_generation' ),
|
|
||||||
'description' => __( 'Stable Diffusion 3. Sharp text, complex scenes.', 'prautoblogger' ),
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'id' => 'runware:z-image@turbo',
|
|
||||||
'name' => 'Z-Image Turbo (Runware)',
|
|
||||||
'provider' => 'runware',
|
|
||||||
'cost_per_image' => 0.0013,
|
|
||||||
'capabilities' => array( 'image_generation' ),
|
|
||||||
'description' => __( 'Fast photorealistic generation.', 'prautoblogger' ),
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'id' => 'runware:101@1',
|
|
||||||
'name' => 'FLUX.1 dev (Runware)',
|
|
||||||
'provider' => 'runware',
|
|
||||||
'cost_per_image' => 0.02,
|
|
||||||
'capabilities' => array( 'image_generation' ),
|
|
||||||
'description' => __( 'Higher fidelity FLUX.1. ~30x schnell cost.', 'prautoblogger' ),
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'id' => 'runware:97@3',
|
|
||||||
'name' => 'HiDream-I1 Fast (Runware)',
|
|
||||||
'provider' => 'runware',
|
|
||||||
'cost_per_image' => 0.0038,
|
|
||||||
'capabilities' => array( 'image_generation' ),
|
|
||||||
'description' => __( 'HiDream-I1 Fast. Low latency, 17B quality.', 'prautoblogger' ),
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'id' => 'runware:97@2',
|
|
||||||
'name' => 'HiDream-I1 Dev (Runware)',
|
|
||||||
'provider' => 'runware',
|
|
||||||
'cost_per_image' => 0.0045,
|
|
||||||
'capabilities' => array( 'image_generation' ),
|
|
||||||
'description' => __( 'HiDream-I1 Dev. Strong prompt alignment.', 'prautoblogger' ),
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'id' => 'runware:z-image@0',
|
|
||||||
'name' => 'Z-Image (Runware)',
|
|
||||||
'provider' => 'runware',
|
|
||||||
'cost_per_image' => 0.0045,
|
|
||||||
'capabilities' => array( 'image_generation' ),
|
|
||||||
'description' => __( 'High-quality Z-Image foundation model.', 'prautoblogger' ),
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'id' => 'runware:400@1',
|
|
||||||
'name' => 'FLUX.2 dev (Runware)',
|
|
||||||
'provider' => 'runware',
|
|
||||||
'cost_per_image' => 0.0051,
|
|
||||||
'capabilities' => array( 'image_generation' ),
|
|
||||||
'description' => __( 'FLUX.2 dev. Controllable open text-to-image. From $0.0051.', 'prautoblogger' ),
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'id' => 'runware:108@1',
|
|
||||||
'name' => 'Qwen-Image (Runware)',
|
|
||||||
'provider' => 'runware',
|
|
||||||
'cost_per_image' => 0.0058,
|
|
||||||
'capabilities' => array( 'image_generation' ),
|
|
||||||
'description' => __( 'Alibaba Qwen-Image. Strong text rendering in generated images.', 'prautoblogger' ),
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'id' => 'runware:97@1',
|
|
||||||
'name' => 'HiDream-I1 Full (Runware)',
|
|
||||||
'provider' => 'runware',
|
|
||||||
'cost_per_image' => 0.009,
|
|
||||||
'capabilities' => array( 'image_generation' ),
|
|
||||||
'description' => __( 'HiDream-I1 Full. 17B high-fidelity, LoRA support.', 'prautoblogger' ),
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'id' => 'runware:107@1',
|
|
||||||
'name' => 'FLUX.1 Krea dev (Runware)',
|
|
||||||
'provider' => 'runware',
|
|
||||||
'cost_per_image' => 0.0098,
|
|
||||||
'capabilities' => array( 'image_generation' ),
|
|
||||||
'description' => __( 'FLUX.1 Krea Dev. Photorealistic open-weight generation.', 'prautoblogger' ),
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'id' => 'runware:glm-image@0',
|
|
||||||
'name' => 'GLM-Image (Runware)',
|
|
||||||
'provider' => 'runware',
|
|
||||||
'cost_per_image' => 0.0225,
|
|
||||||
'capabilities' => array( 'image_generation' ),
|
|
||||||
'description' => __( 'GLM-Image. Hybrid autoregressive+diffusion, excellent text rendering.', 'prautoblogger' ),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hardcoded OpenRouter image model list. Left static for now because
|
|
||||||
* OpenRouter's catalog is large (hundreds of models) with noisy filtering.
|
|
||||||
* Future iteration will add smart filtering strategy (output_modalities,
|
|
||||||
* pricing, rate limits).
|
|
||||||
*
|
|
||||||
* Last verified: 2026-04-26. To update: check openrouter.ai/api/v1/models
|
|
||||||
* filtering to output_modalities containing 'image'.
|
|
||||||
*
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private static function get_openrouter_models(): array {
|
|
||||||
return array(
|
|
||||||
array(
|
|
||||||
'id' => 'google/gemini-2.5-flash-image',
|
|
||||||
'name' => 'Gemini 2.5 Flash Image (OpenRouter)',
|
|
||||||
'provider' => 'openrouter',
|
|
||||||
'cost_per_image' => 0.005,
|
|
||||||
'capabilities' => array( 'image_generation' ),
|
|
||||||
'description' => __( 'Mid-tier quality, proven.', 'prautoblogger' ),
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'id' => 'google/gemini-3.1-flash-image-preview',
|
|
||||||
'name' => 'Gemini 3.1 Flash Image (OpenRouter)',
|
|
||||||
'provider' => 'openrouter',
|
|
||||||
'cost_per_image' => 0.008,
|
|
||||||
'capabilities' => array( 'image_generation' ),
|
|
||||||
'description' => __( 'Latest Google Flash image. Better quality.', 'prautoblogger' ),
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'id' => 'openai/gpt-5-image-mini',
|
|
||||||
'name' => 'GPT-5 Image Mini (OpenRouter)',
|
|
||||||
'provider' => 'openrouter',
|
|
||||||
'cost_per_image' => 0.02,
|
|
||||||
'capabilities' => array( 'image_generation' ),
|
|
||||||
'description' => __( 'OpenAI budget image model.', 'prautoblogger' ),
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'id' => 'openai/gpt-5.4-image-2',
|
|
||||||
'name' => 'GPT-5.4 Image 2 (OpenRouter)',
|
|
||||||
'provider' => 'openrouter',
|
|
||||||
'cost_per_image' => 0.02,
|
|
||||||
'capabilities' => array( 'image_generation' ),
|
|
||||||
'description' => __( 'OpenAI GPT-5.4 image generation.', 'prautoblogger' ),
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'id' => 'google/gemini-3-pro-image-preview',
|
|
||||||
'name' => 'Gemini 3 Pro Image (OpenRouter)',
|
|
||||||
'provider' => 'openrouter',
|
|
||||||
'cost_per_image' => 0.03,
|
|
||||||
'capabilities' => array( 'image_generation' ),
|
|
||||||
'description' => __( 'Highest quality Google image. Premium.', 'prautoblogger' ),
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'id' => 'openai/gpt-5-image',
|
|
||||||
'name' => 'GPT-5 Image (OpenRouter)',
|
|
||||||
'provider' => 'openrouter',
|
|
||||||
'cost_per_image' => 0.08,
|
|
||||||
'capabilities' => array( 'image_generation' ),
|
|
||||||
'description' => __( 'OpenAI premium. Photorealistic.', 'prautoblogger' ),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opik settings UI and registration.
|
|
||||||
*
|
|
||||||
* Registers settings fields in the PRAutoBlogger Settings page under
|
|
||||||
* a new "Observability" section. Fields include:
|
|
||||||
* - Enable Opik tracing (checkbox, default off)
|
|
||||||
* - Opik project name (text, default 'prautoblogger')
|
|
||||||
* - Capture full prompts (checkbox, default off)
|
|
||||||
* - Status display (read-only: API key configured, last dispatch, queue depth)
|
|
||||||
*
|
|
||||||
* Never renders the API key value in admin HTML.
|
|
||||||
*
|
|
||||||
* @see includes/admin/class-admin-page.php — Main settings page loader.
|
|
||||||
*/
|
|
||||||
class PRAutoBlogger_Opik_Settings {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constructor.
|
|
||||||
*
|
|
||||||
* Register hooks in __construct so they fire when the plugin loads.
|
|
||||||
*/
|
|
||||||
public function __construct() {
|
|
||||||
add_action( 'admin_init', array( $this, 'register_settings' ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register Opik settings section and fields.
|
|
||||||
*
|
|
||||||
* Hooked to 'admin_init'.
|
|
||||||
*/
|
|
||||||
public function register_settings(): void {
|
|
||||||
// Register the section.
|
|
||||||
add_settings_section(
|
|
||||||
'prautoblogger_observability',
|
|
||||||
__( 'Observability (Opik)', 'prautoblogger' ),
|
|
||||||
array( $this, 'render_section_help' ),
|
|
||||||
'prautoblogger_settings'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Register fields.
|
|
||||||
add_settings_field(
|
|
||||||
'prautoblogger_opik_enabled',
|
|
||||||
__( 'Enable Opik tracing', 'prautoblogger' ),
|
|
||||||
array( $this, 'render_field_enabled' ),
|
|
||||||
'prautoblogger_settings',
|
|
||||||
'prautoblogger_observability'
|
|
||||||
);
|
|
||||||
|
|
||||||
register_setting(
|
|
||||||
'prautoblogger_settings',
|
|
||||||
'prautoblogger_opik_enabled',
|
|
||||||
array( 'type' => 'boolean' )
|
|
||||||
);
|
|
||||||
|
|
||||||
add_settings_field(
|
|
||||||
'prautoblogger_opik_project_name',
|
|
||||||
__( 'Opik project name', 'prautoblogger' ),
|
|
||||||
array( $this, 'render_field_project_name' ),
|
|
||||||
'prautoblogger_settings',
|
|
||||||
'prautoblogger_observability'
|
|
||||||
);
|
|
||||||
|
|
||||||
register_setting(
|
|
||||||
'prautoblogger_settings',
|
|
||||||
'prautoblogger_opik_project_name',
|
|
||||||
array(
|
|
||||||
'type' => 'string',
|
|
||||||
'sanitize_callback' => 'sanitize_text_field',
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
add_settings_field(
|
|
||||||
'prautoblogger_opik_capture_full_prompt',
|
|
||||||
__( 'Capture full prompt text', 'prautoblogger' ),
|
|
||||||
array( $this, 'render_field_capture_full' ),
|
|
||||||
'prautoblogger_settings',
|
|
||||||
'prautoblogger_observability'
|
|
||||||
);
|
|
||||||
|
|
||||||
register_setting(
|
|
||||||
'prautoblogger_settings',
|
|
||||||
'prautoblogger_opik_capture_full_prompt',
|
|
||||||
array( 'type' => 'boolean' )
|
|
||||||
);
|
|
||||||
|
|
||||||
add_settings_field(
|
|
||||||
'prautoblogger_opik_status',
|
|
||||||
__( 'Status', 'prautoblogger' ),
|
|
||||||
array( $this, 'render_field_status' ),
|
|
||||||
'prautoblogger_settings',
|
|
||||||
'prautoblogger_observability'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render section help text.
|
|
||||||
*/
|
|
||||||
public function render_section_help(): void {
|
|
||||||
echo wp_kses_post(
|
|
||||||
'<p>Optional LLM observability via <a href="https://www.comet.com/" target="_blank" rel="noopener">Comet Opik</a>. ' .
|
|
||||||
'Captures detailed traces of article generation with per-LLM-call metrics. ' .
|
|
||||||
'Requires API credentials in <code>wp-config.php</code>.</p>'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render the "Enable Opik" checkbox.
|
|
||||||
*/
|
|
||||||
public function render_field_enabled(): void {
|
|
||||||
$enabled = get_option( 'prautoblogger_opik_enabled', false );
|
|
||||||
$checked = $enabled ? 'checked' : '';
|
|
||||||
?>
|
|
||||||
<input type="checkbox" name="prautoblogger_opik_enabled" value="1" <?php echo esc_attr( $checked ); ?> />
|
|
||||||
<label for="prautoblogger_opik_enabled">
|
|
||||||
<?php esc_html_e( 'Send article generation traces to Opik', 'prautoblogger' ); ?>
|
|
||||||
</label>
|
|
||||||
<?php
|
|
||||||
if ( ! $this->has_credentials() ) {
|
|
||||||
echo '<p style="color: #d63638;"><strong>' .
|
|
||||||
esc_html__( 'Note: API credentials must be set in wp-config.php (PRAUTOBLOGGER_OPIK_API_KEY, PRAUTOBLOGGER_OPIK_WORKSPACE)', 'prautoblogger' ) .
|
|
||||||
'</strong></p>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render the project name field.
|
|
||||||
*/
|
|
||||||
public function render_field_project_name(): void {
|
|
||||||
$value = get_option( 'prautoblogger_opik_project_name', 'prautoblogger' );
|
|
||||||
?>
|
|
||||||
<input type="text" name="prautoblogger_opik_project_name" value="<?php echo esc_attr( $value ); ?>" style="width: 300px;" />
|
|
||||||
<p class="description">
|
|
||||||
<?php esc_html_e( 'Opik auto-creates this project on first trace (lowercase, no spaces)', 'prautoblogger' ); ?>
|
|
||||||
</p>
|
|
||||||
<?php
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render the "Capture full prompt" checkbox.
|
|
||||||
*/
|
|
||||||
public function render_field_capture_full(): void {
|
|
||||||
$enabled = get_option( 'prautoblogger_opik_capture_full_prompt', false );
|
|
||||||
$checked = $enabled ? 'checked' : '';
|
|
||||||
?>
|
|
||||||
<input type="checkbox" name="prautoblogger_opik_capture_full_prompt" value="1" <?php echo esc_attr( $checked ); ?> />
|
|
||||||
<label>
|
|
||||||
<?php esc_html_e( 'Include full prompt text in spans (increases payload size)', 'prautoblogger' ); ?>
|
|
||||||
</label>
|
|
||||||
<p class="description">
|
|
||||||
<?php esc_html_e( 'By default, only prompt hash is logged. Enable this for debugging.' ); ?>
|
|
||||||
</p>
|
|
||||||
<?php
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render the status display (read-only).
|
|
||||||
*/
|
|
||||||
public function render_field_status(): void {
|
|
||||||
$has_api_key = $this->has_credentials();
|
|
||||||
$queue = new PRAutoBlogger_Opik_Span_Queue();
|
|
||||||
$queue_depth = $queue->get_queue_depth();
|
|
||||||
$last_dispatch = get_option( 'prautoblogger_opik_last_dispatch', 0 );
|
|
||||||
$last_dispatch_str = $last_dispatch
|
|
||||||
? sprintf(
|
|
||||||
/* translators: %s = human-readable time */
|
|
||||||
__( '%s ago', 'prautoblogger' ),
|
|
||||||
human_time_diff( (int) $last_dispatch )
|
|
||||||
)
|
|
||||||
: __( 'Never', 'prautoblogger' );
|
|
||||||
|
|
||||||
?>
|
|
||||||
<div style="background: #f1f1f1; padding: 10px; border-radius: 3px; max-width: 600px;">
|
|
||||||
<p>
|
|
||||||
<strong><?php esc_html_e( 'API key configured:', 'prautoblogger' ); ?></strong>
|
|
||||||
<?php echo $has_api_key ? '<span style="color: green;">✓ Yes</span>' : '<span style="color: red;">✗ No</span>'; ?>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong><?php esc_html_e( 'Last dispatch:', 'prautoblogger' ); ?></strong>
|
|
||||||
<?php echo esc_html( $last_dispatch_str ); ?>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong><?php esc_html_e( 'Queue depth:', 'prautoblogger' ); ?></strong>
|
|
||||||
<?php echo esc_html( (string) $queue_depth ); ?>
|
|
||||||
<?php esc_html_e( 'item(s) pending', 'prautoblogger' ); ?>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<?php
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if Opik credentials are configured in wp-config.php.
|
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
private function has_credentials(): bool {
|
|
||||||
return defined( 'PRAUTOBLOGGER_OPIK_API_KEY' ) &&
|
|
||||||
defined( 'PRAUTOBLOGGER_OPIK_WORKSPACE' ) &&
|
|
||||||
! empty( PRAUTOBLOGGER_OPIK_API_KEY ) &&
|
|
||||||
! empty( PRAUTOBLOGGER_OPIK_WORKSPACE );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -111,15 +111,10 @@ class PRAutoBlogger_Post_List_Columns {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
echo '<style>'
|
echo '<style>'
|
||||||
. '.wp-list-table .column-title{width:22%}'
|
. '.wp-list-table .column-title{width:35%;word-wrap:break-word;overflow-wrap:break-word}'
|
||||||
. '.column-author{width:6%}'
|
. '.column-prab_writing_model{width:100px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}'
|
||||||
. '.column-categories{width:7%}'
|
. '.column-prab_image_model{width:100px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}'
|
||||||
. '.column-tags{width:9%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}'
|
. '.column-prab_cost{width:55px;white-space:nowrap;overflow:visible}'
|
||||||
. '.column-date{width:8%}'
|
|
||||||
. '.column-prab_writing_model{width:85px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}'
|
|
||||||
. '.column-prab_image_model{width:85px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}'
|
|
||||||
. '.column-prab_cost{width:50px;white-space:nowrap;overflow:visible}'
|
|
||||||
. '.column-taxonomy-peptide_topic{width:75px}'
|
|
||||||
. '.prab-cost-wrap{position:relative;display:inline-block}'
|
. '.prab-cost-wrap{position:relative;display:inline-block}'
|
||||||
. '.prab-cost-trigger{font-size:12px;color:#2271b1;text-decoration:underline;cursor:pointer}'
|
. '.prab-cost-trigger{font-size:12px;color:#2271b1;text-decoration:underline;cursor:pointer}'
|
||||||
. '.prab-cost-popover{display:none;position:absolute;right:0;top:100%;z-index:9999;'
|
. '.prab-cost-popover{display:none;position:absolute;right:0;top:100%;z-index:9999;'
|
||||||
@@ -187,9 +182,8 @@ class PRAutoBlogger_Post_List_Columns {
|
|||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
$rows = $wpdb->get_results(
|
$rows = $wpdb->get_results(
|
||||||
$wpdb->prepare(
|
$wpdb->prepare(
|
||||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
"SELECT stage, model, prompt_tokens, completion_tokens, estimated_cost // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
"SELECT stage, model, prompt_tokens, completion_tokens, estimated_cost
|
FROM {$table} WHERE post_id = %d ORDER BY id ASC",
|
||||||
FROM {$table} WHERE post_id = %d ORDER BY id ASC",
|
|
||||||
$post_id
|
$post_id
|
||||||
),
|
),
|
||||||
ARRAY_A
|
ARRAY_A
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ declare(strict_types=1);
|
|||||||
*
|
*
|
||||||
* What: Declarative field definitions for operational settings sections.
|
* What: Declarative field definitions for operational settings sections.
|
||||||
* Who calls it: PRAutoBlogger_Settings_Fields::get_fields() merges these in.
|
* Who calls it: PRAutoBlogger_Settings_Fields::get_fields() merges these in.
|
||||||
* Dependencies: PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE constants,
|
* Dependencies: PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX constants,
|
||||||
* PRAutoBlogger_Article_Typography (font choices), PRAutoBlogger_Image_Model_Registry.
|
* PRAutoBlogger_Article_Typography (font choices), PRAutoBlogger_Image_Model_Registry.
|
||||||
*
|
*
|
||||||
* @see admin/class-settings-fields.php — Core fields and sections; calls get_extended_fields().
|
* @see admin/class-settings-fields.php — Core fields and sections; calls get_extended_fields().
|
||||||
@@ -235,12 +235,12 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
|||||||
'icon' => '🔑',
|
'icon' => '🔑',
|
||||||
),
|
),
|
||||||
array(
|
array(
|
||||||
'id' => 'prautoblogger_image_style_template',
|
'id' => 'prautoblogger_image_style_suffix',
|
||||||
'label' => __( 'Style Template', 'prautoblogger' ),
|
'label' => __( 'Style Suffix', 'prautoblogger' ),
|
||||||
'type' => 'textarea',
|
'type' => 'textarea',
|
||||||
'section' => 'prautoblogger_images',
|
'section' => 'prautoblogger_images',
|
||||||
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE,
|
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX,
|
||||||
'description' => __( 'The full image prompt template. Must contain exactly one {{ topic_summary }} token, which is filled with a 1-2 sentence topic/mechanism summary per article. The rest is brand-locked editorial style. Replaces the old Style Suffix (now deprecated). Changing mid-run causes visible style drift.', 'prautoblogger' ),
|
'description' => __( 'Appended to every image prompt. Controls visual look. Changing mid-run causes visible style drift.', 'prautoblogger' ),
|
||||||
),
|
),
|
||||||
array(
|
array(
|
||||||
'id' => 'prautoblogger_image_prompt_instructions',
|
'id' => 'prautoblogger_image_prompt_instructions',
|
||||||
@@ -248,7 +248,7 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
|||||||
'type' => 'textarea',
|
'type' => 'textarea',
|
||||||
'section' => 'prautoblogger_images',
|
'section' => 'prautoblogger_images',
|
||||||
'default' => PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT,
|
'default' => PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT,
|
||||||
'description' => __( 'System prompt given to the rewriter LLM that turns each article into a SCENE (a 1-2 sentence editorial topic/mechanism summary, substituted into the Style Template) plus a CAPTION (HTML text shown below the image). Changing this reshapes the look of all future images. Leave blank to use the default.', 'prautoblogger' ),
|
'description' => __( 'System prompt given to the rewriter LLM that turns each article into a SCENE + CAPTION for the image generator. Changing this reshapes the look of all future images. Leave blank to use the default.', 'prautoblogger' ),
|
||||||
),
|
),
|
||||||
array(
|
array(
|
||||||
'id' => 'prautoblogger_image_nsfw_retry',
|
'id' => 'prautoblogger_image_nsfw_retry',
|
||||||
@@ -258,13 +258,6 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
|||||||
'default' => '1',
|
'default' => '1',
|
||||||
'description' => __( 'When the provider rejects an image prompt as NSFW, retry once with a generic fallback scene built from the article title. Disable to fail fast if the filter gets trigger-happy.', 'prautoblogger' ),
|
'description' => __( 'When the provider rejects an image prompt as NSFW, retry once with a generic fallback scene built from the article title. Disable to fail fast if the filter gets trigger-happy.', 'prautoblogger' ),
|
||||||
),
|
),
|
||||||
array(
|
|
||||||
'id' => 'prautoblogger_runware_model_catalog',
|
|
||||||
'label' => __( 'Runware Model Catalog', 'prautoblogger' ),
|
|
||||||
'type' => 'runware_catalog_sync',
|
|
||||||
'section' => 'prautoblogger_images',
|
|
||||||
'description' => __( 'Live model catalog sync status and on-demand refresh.', 'prautoblogger' ),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WP-CLI commands for PRAutoBlogger.
|
|
||||||
*
|
|
||||||
* Registers custom wp-cli commands exposed to the plugin.
|
|
||||||
*/
|
|
||||||
class PRAutoBlogger_WP_CLI_Commands {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register WP-CLI commands.
|
|
||||||
*
|
|
||||||
* Called on plugins_loaded hook.
|
|
||||||
*/
|
|
||||||
public static function register(): void {
|
|
||||||
if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Command: wp prautoblogger opik:eval
|
|
||||||
\WP_CLI::add_command(
|
|
||||||
'prautoblogger opik:eval',
|
|
||||||
array( self::class, 'opik_eval_command' ),
|
|
||||||
array(
|
|
||||||
'shortdesc' => 'Run Opik evals on the frozen dataset',
|
|
||||||
'synopsis' => array(
|
|
||||||
array(
|
|
||||||
'type' => 'assoc',
|
|
||||||
'name' => 'limit',
|
|
||||||
'description' => 'Max items to run (0 = all)',
|
|
||||||
'optional' => true,
|
|
||||||
'default' => '0',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'type' => 'flag',
|
|
||||||
'name' => 'dry-run',
|
|
||||||
'description' => 'Skip Opik API push; score locally only',
|
|
||||||
'optional' => true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run eval on the frozen dataset.
|
|
||||||
*
|
|
||||||
* @param array $args Positional args (unused).
|
|
||||||
* @param array $assoc_args Associative args: limit, dry-run.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public static function opik_eval_command( array $args, array $assoc_args ): void {
|
|
||||||
// Check if Opik is enabled.
|
|
||||||
if ( empty( PRAUTOBLOGGER_OPIK_API_KEY ) || empty( PRAUTOBLOGGER_OPIK_WORKSPACE ) ) {
|
|
||||||
if ( ! isset( $assoc_args['dry-run'] ) ) {
|
|
||||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_echo -- CLI output
|
|
||||||
echo "Warning: Opik is not configured. Running with --dry-run only.\n";
|
|
||||||
$assoc_args['dry-run'] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$limit = absint( $assoc_args['limit'] ?? 0 );
|
|
||||||
$dry_run = isset( $assoc_args['dry-run'] );
|
|
||||||
|
|
||||||
$runner = new PRAutoBlogger_Opik_Eval_Runner();
|
|
||||||
$result = $runner->run( $limit, $dry_run );
|
|
||||||
|
|
||||||
exit( $result['items_run'] > 0 ? 0 : 1 );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -130,11 +130,6 @@ class PRAutoBlogger_Activator {
|
|||||||
wp_schedule_event( $tomorrow, 'daily', 'prautoblogger_reap_orphan_research_rows' );
|
wp_schedule_event( $tomorrow, 'daily', 'prautoblogger_reap_orphan_research_rows' );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// v0.15.0: daily Runware model catalog sync.
|
|
||||||
if ( ! wp_next_scheduled( 'prautoblogger_sync_runware_models' ) ) {
|
|
||||||
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'prautoblogger_sync_runware_models' );
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -151,53 +151,6 @@ class PRAutoBlogger_Ajax_Handlers {
|
|||||||
wp_send_json_success( $results );
|
wp_send_json_success( $results );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* AJAX handler: sync Runware model catalog on demand.
|
|
||||||
*
|
|
||||||
* Nonce-protected and requires manage_options capability.
|
|
||||||
* Returns the updated cache timestamp and model count.
|
|
||||||
*
|
|
||||||
* Side effects: makes HTTP request to Runware API, writes WP options.
|
|
||||||
*/
|
|
||||||
public function on_ajax_sync_runware_models_now(): void {
|
|
||||||
check_ajax_referer( 'prautoblogger_sync_runware_models_now', 'nonce' );
|
|
||||||
|
|
||||||
if ( ! current_user_can( 'manage_options' ) ) {
|
|
||||||
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$catalog = new PRAutoBlogger_Runware_Model_Catalog();
|
|
||||||
$success = $catalog->sync();
|
|
||||||
|
|
||||||
if ( ! $success ) {
|
|
||||||
wp_send_json_error( array( 'message' => __( 'Catalog sync failed. Check logs for details.', 'prautoblogger' ) ) );
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$models = $catalog->get_models();
|
|
||||||
$synced_at = $catalog->get_last_synced_at();
|
|
||||||
$synced_at_str = $synced_at
|
|
||||||
? wp_date( __( 'Y-m-d H:i:s', 'prautoblogger' ), $synced_at )
|
|
||||||
: __( 'Never', 'prautoblogger' );
|
|
||||||
|
|
||||||
wp_send_json_success(
|
|
||||||
array(
|
|
||||||
'message' => __( 'Runware model catalog synced successfully.', 'prautoblogger' ),
|
|
||||||
'model_count' => count( $models ),
|
|
||||||
'synced_at' => $synced_at_str,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} catch ( \Throwable $e ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->error(
|
|
||||||
sprintf( 'AJAX sync Runware models failed: %s (%s)', $e->getMessage(), get_class( $e ) ),
|
|
||||||
'runware-catalog'
|
|
||||||
);
|
|
||||||
wp_send_json_error( array( 'message' => $e->getMessage() ) );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the shared model registry instance.
|
* Get the shared model registry instance.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ class PRAutoBlogger_Autoloader {
|
|||||||
'models/',
|
'models/',
|
||||||
'frontend/',
|
'frontend/',
|
||||||
'services/',
|
'services/',
|
||||||
'services/opik/',
|
|
||||||
'ajax/',
|
'ajax/',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
1
includes/class-deactivator.php
Normal file → Executable file
1
includes/class-deactivator.php
Normal file → Executable file
@@ -39,7 +39,6 @@ class PRAutoBlogger_Deactivator {
|
|||||||
'prautoblogger_daily_generation',
|
'prautoblogger_daily_generation',
|
||||||
'prautoblogger_collect_metrics',
|
'prautoblogger_collect_metrics',
|
||||||
'prautoblogger_reap_orphan_research_rows',
|
'prautoblogger_reap_orphan_research_rows',
|
||||||
'prautoblogger_sync_runware_models',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
foreach ( $hooks as $hook ) {
|
foreach ( $hooks as $hook ) {
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ class PRAutoBlogger {
|
|||||||
$this->register_cron_hooks();
|
$this->register_cron_hooks();
|
||||||
$this->register_frontend_hooks();
|
$this->register_frontend_hooks();
|
||||||
$this->register_ajax_hooks();
|
$this->register_ajax_hooks();
|
||||||
$this->register_cli_hooks();
|
|
||||||
|
|
||||||
/** Fires after PRAutoBlogger has finished registering all hooks. */
|
/** Fires after PRAutoBlogger has finished registering all hooks. */
|
||||||
do_action( 'prautoblogger_loaded' );
|
do_action( 'prautoblogger_loaded' );
|
||||||
@@ -116,32 +115,6 @@ class PRAutoBlogger {
|
|||||||
|
|
||||||
$registry = $this->executor->get_model_registry();
|
$registry = $this->executor->get_model_registry();
|
||||||
add_action( 'prautoblogger_refresh_model_registry', array( $registry, 'refresh' ) );
|
add_action( 'prautoblogger_refresh_model_registry', array( $registry, 'refresh' ) );
|
||||||
// v0.15.0: daily Runware model catalog sync to keep the live model list fresh.
|
|
||||||
add_action( 'prautoblogger_sync_runware_models', array( new PRAutoBlogger_Runware_Model_Catalog(), 'sync' ) );
|
|
||||||
|
|
||||||
// Opik observability: async trace/span dispatch.
|
|
||||||
add_action(
|
|
||||||
'prautoblogger_opik_dispatch',
|
|
||||||
static function (): void {
|
|
||||||
if ( ! defined( 'PRAUTOBLOGGER_OPIK_API_KEY' ) || empty( PRAUTOBLOGGER_OPIK_API_KEY ) ) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$client = new PRAutoBlogger_Opik_Client(
|
|
||||||
PRAUTOBLOGGER_OPIK_API_KEY,
|
|
||||||
PRAUTOBLOGGER_OPIK_WORKSPACE,
|
|
||||||
PRAUTOBLOGGER_OPIK_URL_OVERRIDE
|
|
||||||
);
|
|
||||||
$queue = new PRAutoBlogger_Opik_Span_Queue();
|
|
||||||
$dispatcher = new PRAutoBlogger_Opik_Dispatcher( $client, $queue );
|
|
||||||
$dispatcher->dispatch();
|
|
||||||
update_option( 'prautoblogger_opik_last_dispatch', time() );
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Opik settings registration.
|
|
||||||
if ( is_admin() ) {
|
|
||||||
new PRAutoBlogger_Opik_Settings();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single-idea generation from the Ideas browser page.
|
// Single-idea generation from the Ideas browser page.
|
||||||
add_action( 'prautoblogger_generate_from_idea', array( 'PRAutoBlogger_Ideas_Browser', 'on_cron_generate_from_idea' ) );
|
add_action( 'prautoblogger_generate_from_idea', array( 'PRAutoBlogger_Ideas_Browser', 'on_cron_generate_from_idea' ) );
|
||||||
@@ -181,7 +154,6 @@ class PRAutoBlogger {
|
|||||||
add_action( 'wp_ajax_prautoblogger_test_connection', array( $this->ajax_handlers, 'on_ajax_test_connection' ) );
|
add_action( 'wp_ajax_prautoblogger_test_connection', array( $this->ajax_handlers, 'on_ajax_test_connection' ) );
|
||||||
add_action( 'wp_ajax_prautoblogger_get_models', array( $this->ajax_handlers, 'on_ajax_get_models' ) );
|
add_action( 'wp_ajax_prautoblogger_get_models', array( $this->ajax_handlers, 'on_ajax_get_models' ) );
|
||||||
add_action( 'wp_ajax_prautoblogger_refresh_models', array( new PRAutoBlogger_Model_Registry_Refresh( $this->ajax_handlers->get_registry() ), 'handle' ) );
|
add_action( 'wp_ajax_prautoblogger_refresh_models', array( new PRAutoBlogger_Model_Registry_Refresh( $this->ajax_handlers->get_registry() ), 'handle' ) );
|
||||||
add_action( 'wp_ajax_prautoblogger_sync_runware_models_now', array( $this->ajax_handlers, 'on_ajax_sync_runware_models_now' ) );
|
|
||||||
|
|
||||||
$review_queue = new PRAutoBlogger_Review_Queue();
|
$review_queue = new PRAutoBlogger_Review_Queue();
|
||||||
add_action( 'wp_ajax_prautoblogger_approve_post', array( $review_queue, 'on_ajax_approve_post' ) );
|
add_action( 'wp_ajax_prautoblogger_approve_post', array( $review_queue, 'on_ajax_approve_post' ) );
|
||||||
@@ -267,24 +239,6 @@ class PRAutoBlogger {
|
|||||||
update_option( 'prautoblogger_migrated_style_suffix_v4', '1' );
|
update_option( 'prautoblogger_migrated_style_suffix_v4', '1' );
|
||||||
}
|
}
|
||||||
|
|
||||||
// One-time migration (v0.16.0): editorial pivot. The comic Style Suffix
|
|
||||||
// is replaced by the editorial Style Template. Mirror the old suffix
|
|
||||||
// value into a deprecated-keyed option for one version cycle (so a
|
|
||||||
// customised value is recoverable / auditable) and seed the new
|
|
||||||
// template with its editorial default. The old option is intentionally
|
|
||||||
// NOT deleted this cycle. See docs/proposals/2026-05-29-image-pipeline-in-plugin-brief.md §8 Commit 1.
|
|
||||||
if ( ! get_option( 'prautoblogger_migrated_style_template_v0160' ) ) {
|
|
||||||
$old_suffix = get_option( 'prautoblogger_image_style_suffix', '' );
|
|
||||||
if ( '' !== $old_suffix && false === get_option( 'prautoblogger_image_style_suffix_deprecated', false ) ) {
|
|
||||||
update_option( 'prautoblogger_image_style_suffix_deprecated', $old_suffix );
|
|
||||||
}
|
|
||||||
// Only seed the new template if the admin has not already set one.
|
|
||||||
if ( '' === (string) get_option( 'prautoblogger_image_style_template', '' ) ) {
|
|
||||||
update_option( 'prautoblogger_image_style_template', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE );
|
|
||||||
}
|
|
||||||
update_option( 'prautoblogger_migrated_style_template_v0160', '1' );
|
|
||||||
}
|
|
||||||
|
|
||||||
// v0.9.0 — Runware as default image model. v0.10.0 — remove CF Workers AI.
|
// v0.9.0 — Runware as default image model. v0.10.0 — remove CF Workers AI.
|
||||||
PRAutoBlogger_Activator::migrate_default_image_v090();
|
PRAutoBlogger_Activator::migrate_default_image_v090();
|
||||||
PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
|
PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
|
||||||
@@ -316,11 +270,6 @@ class PRAutoBlogger {
|
|||||||
return $schedules;
|
return $schedules;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Register WP-CLI commands. */
|
|
||||||
private function register_cli_hooks(): void {
|
|
||||||
PRAutoBlogger_WP_CLI_Commands::register();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Block false update notifications from wordpress.org.
|
* Block false update notifications from wordpress.org.
|
||||||
* Our plugin slug may collide with a different plugin in the directory.
|
* Our plugin slug may collide with a different plugin in the directory.
|
||||||
|
|||||||
@@ -12,14 +12,10 @@ declare(strict_types=1);
|
|||||||
* Triggered by: Pipeline_Runner (directly for article 1, via cron for 2..N).
|
* Triggered by: Pipeline_Runner (directly for article 1, via cron for 2..N).
|
||||||
* Dependencies: Content_Generator, Chief_Editor, Publisher, Cost_Tracker.
|
* Dependencies: Content_Generator, Chief_Editor, Publisher, Cost_Tracker.
|
||||||
*
|
*
|
||||||
* Opik instrumentation: Initializes trace context at start, creates spans
|
|
||||||
* for content generation and editorial review. Feature-flag gated (default off).
|
|
||||||
*
|
|
||||||
* @see core/class-pipeline-runner.php — Orchestrates and dispatches workers.
|
* @see core/class-pipeline-runner.php — Orchestrates and dispatches workers.
|
||||||
* @see core/class-content-generator.php — LLM content generation.
|
* @see core/class-content-generator.php — LLM content generation.
|
||||||
* @see core/class-chief-editor.php — Editorial quality gate.
|
* @see core/class-chief-editor.php — Editorial quality gate.
|
||||||
* @see core/class-publisher.php — WordPress post creation.
|
* @see core/class-publisher.php — WordPress post creation.
|
||||||
* @see services/opik/class-opik-trace-context.php — Opik tracing.
|
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger_Article_Worker {
|
class PRAutoBlogger_Article_Worker {
|
||||||
|
|
||||||
@@ -38,22 +34,14 @@ class PRAutoBlogger_Article_Worker {
|
|||||||
* Runs the full single-article pipeline: LLM content generation →
|
* Runs the full single-article pipeline: LLM content generation →
|
||||||
* editorial review → publish or save as draft.
|
* editorial review → publish or save as draft.
|
||||||
*
|
*
|
||||||
* Initializes Opik trace context if enabled and tears it down at end.
|
|
||||||
*
|
|
||||||
* Side effects: LLM API calls, database writes, WordPress post creation,
|
* Side effects: LLM API calls, database writes, WordPress post creation,
|
||||||
* image generation, cost logging, Opik span queuing.
|
* image generation, cost logging.
|
||||||
*
|
*
|
||||||
* @param PRAutoBlogger_Article_Idea $idea The scored idea to generate from.
|
* @param PRAutoBlogger_Article_Idea $idea The scored idea to generate from.
|
||||||
*
|
*
|
||||||
* @return array{generated: int, published: int, rejected: int, cost: float}
|
* @return array{generated: int, published: int, rejected: int, cost: float}
|
||||||
*/
|
*/
|
||||||
public function generate( PRAutoBlogger_Article_Idea $idea ): array {
|
public function generate( PRAutoBlogger_Article_Idea $idea ): array {
|
||||||
// Initialize Opik trace context for this article generation.
|
|
||||||
$opik = $this->should_trace_with_opik();
|
|
||||||
if ( $opik ) {
|
|
||||||
PRAutoBlogger_Opik_Trace_Context::current()->init_trace();
|
|
||||||
}
|
|
||||||
|
|
||||||
$llm = new PRAutoBlogger_OpenRouter_Provider();
|
$llm = new PRAutoBlogger_OpenRouter_Provider();
|
||||||
$generator = new PRAutoBlogger_Content_Generator( $llm, $this->cost_tracker );
|
$generator = new PRAutoBlogger_Content_Generator( $llm, $this->cost_tracker );
|
||||||
$editor = new PRAutoBlogger_Chief_Editor( $llm, $this->cost_tracker );
|
$editor = new PRAutoBlogger_Chief_Editor( $llm, $this->cost_tracker );
|
||||||
@@ -102,12 +90,6 @@ class PRAutoBlogger_Article_Worker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$result['cost'] = $this->cost_tracker->get_current_run_cost();
|
$result['cost'] = $this->cost_tracker->get_current_run_cost();
|
||||||
|
|
||||||
// Finalize and queue Opik trace if enabled.
|
|
||||||
if ( $opik ) {
|
|
||||||
$this->finalize_opik_trace();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,13 +120,13 @@ class PRAutoBlogger_Article_Worker {
|
|||||||
: $content;
|
: $content;
|
||||||
|
|
||||||
if ( $auto_publish ) {
|
if ( $auto_publish ) {
|
||||||
$publisher->publish( $final, $idea, $review, $run_id, $this->cost_tracker );
|
$publisher->publish( $final, $idea, $review, $run_id );
|
||||||
++$result['published'];
|
++$result['published'];
|
||||||
} else {
|
} else {
|
||||||
$publisher->save_as_draft( $final, $idea, $review, $run_id, $this->cost_tracker );
|
$publisher->save_as_draft( $final, $idea, $review, $run_id );
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$publisher->save_as_draft( $content, $idea, $review, $run_id, $this->cost_tracker );
|
$publisher->save_as_draft( $content, $idea, $review, $run_id );
|
||||||
++$result['rejected'];
|
++$result['rejected'];
|
||||||
PRAutoBlogger_Logger::instance()->info(
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
'Article rejected by editor: ' . $idea->get_topic(),
|
'Article rejected by editor: ' . $idea->get_topic(),
|
||||||
@@ -167,47 +149,4 @@ class PRAutoBlogger_Article_Worker {
|
|||||||
set_transient( $transient_key, $current, 600 );
|
set_transient( $transient_key, $current, 600 );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if Opik tracing is enabled and has credentials.
|
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
private function should_trace_with_opik(): bool {
|
|
||||||
if ( ! get_option( 'prautoblogger_opik_enabled', false ) ) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return defined( 'PRAUTOBLOGGER_OPIK_API_KEY' ) &&
|
|
||||||
defined( 'PRAUTOBLOGGER_OPIK_WORKSPACE' ) &&
|
|
||||||
! empty( PRAUTOBLOGGER_OPIK_API_KEY ) &&
|
|
||||||
! empty( PRAUTOBLOGGER_OPIK_WORKSPACE );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finalize and queue the Opik trace.
|
|
||||||
*
|
|
||||||
* Dispatches trace to queue for async posting.
|
|
||||||
*/
|
|
||||||
private function finalize_opik_trace(): void {
|
|
||||||
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
|
|
||||||
$trace = $ctx->finalize_trace();
|
|
||||||
$queue = new PRAutoBlogger_Opik_Span_Queue();
|
|
||||||
|
|
||||||
// Queue the trace.
|
|
||||||
$queue->enqueue( $trace, 'trace' );
|
|
||||||
|
|
||||||
// Queue all spans.
|
|
||||||
foreach ( $ctx->get_spans() as $span ) {
|
|
||||||
$queue->enqueue( $span, 'span' );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schedule async dispatch if not already scheduled.
|
|
||||||
if ( ! wp_next_scheduled( 'prautoblogger_opik_dispatch' ) ) {
|
|
||||||
wp_schedule_single_event( time(), 'prautoblogger_opik_dispatch' );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Teardown context for next request.
|
|
||||||
PRAutoBlogger_Opik_Trace_Context::teardown();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,21 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chief Editor agent — LLM-powered editorial review.
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
*
|
*
|
||||||
* Reviews generated articles for quality, accuracy, SEO, tone.
|
* Chief Editor agent — LLM-powered editorial review of generated content.
|
||||||
* Approves, requests revisions, or rejects content.
|
|
||||||
* Opik instrumentation: spans per review LLM call.
|
|
||||||
*
|
*
|
||||||
* Triggered by: PRAutoBlogger_Article_Worker.
|
* Reviews articles for quality, accuracy, SEO, and tone before publishing.
|
||||||
|
* Can approve, request revisions (and produce them), or reject content.
|
||||||
|
* Rejected content is saved as a draft for human review.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger::run_generation_pipeline() (step 5).
|
||||||
* Dependencies: LLM_Provider_Interface, Cost_Tracker.
|
* Dependencies: LLM_Provider_Interface, Cost_Tracker.
|
||||||
|
*
|
||||||
|
* @see core/class-content-generator.php — Produces the content we review.
|
||||||
|
* @see core/class-json-extractor.php — Tolerant JSON parsing for LLM output.
|
||||||
|
* @see core/class-publisher.php — Publishes approved content.
|
||||||
|
* @see ARCHITECTURE.md — Data flow step 5.
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger_Chief_Editor {
|
class PRAutoBlogger_Chief_Editor {
|
||||||
|
|
||||||
@@ -27,8 +34,15 @@ class PRAutoBlogger_Chief_Editor {
|
|||||||
/**
|
/**
|
||||||
* Review generated content and return an editorial verdict.
|
* Review generated content and return an editorial verdict.
|
||||||
*
|
*
|
||||||
|
* The editor evaluates: content quality, factual tone, SEO elements,
|
||||||
|
* readability, and alignment with the article idea. It can approve,
|
||||||
|
* revise (and return revised content), or reject.
|
||||||
|
*
|
||||||
|
* Side effects: LLM API call(s), cost logging.
|
||||||
|
*
|
||||||
* @param string $content The generated HTML content.
|
* @param string $content The generated HTML content.
|
||||||
* @param PRAutoBlogger_Article_Idea $idea The original article idea.
|
* @param PRAutoBlogger_Article_Idea $idea The original article idea.
|
||||||
|
*
|
||||||
* @return PRAutoBlogger_Editorial_Review
|
* @return PRAutoBlogger_Editorial_Review
|
||||||
*/
|
*/
|
||||||
public function review( string $content, PRAutoBlogger_Article_Idea $idea ): PRAutoBlogger_Editorial_Review {
|
public function review( string $content, PRAutoBlogger_Article_Idea $idea ): PRAutoBlogger_Editorial_Review {
|
||||||
@@ -38,16 +52,6 @@ class PRAutoBlogger_Chief_Editor {
|
|||||||
$system_prompt = $this->build_system_prompt( $niche );
|
$system_prompt = $this->build_system_prompt( $niche );
|
||||||
$user_prompt = $this->build_review_prompt( $content, $idea );
|
$user_prompt = $this->build_review_prompt( $content, $idea );
|
||||||
|
|
||||||
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
|
|
||||||
$span_id = $ctx->start_span(
|
|
||||||
array(
|
|
||||||
'name' => 'editorial_review',
|
|
||||||
'type' => 'llm',
|
|
||||||
'model' => $model,
|
|
||||||
'provider' => 'openrouter',
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$response = $this->llm->send_chat_completion(
|
$response = $this->llm->send_chat_completion(
|
||||||
array(
|
array(
|
||||||
array(
|
array(
|
||||||
@@ -67,17 +71,6 @@ class PRAutoBlogger_Chief_Editor {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
$ctx->end_span(
|
|
||||||
$span_id,
|
|
||||||
array(
|
|
||||||
'usage' => array(
|
|
||||||
'prompt_tokens' => $response['prompt_tokens'],
|
|
||||||
'completion_tokens' => $response['completion_tokens'],
|
|
||||||
'total_tokens' => $response['prompt_tokens'] + $response['completion_tokens'],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->cost_tracker->log_api_call(
|
$this->cost_tracker->log_api_call(
|
||||||
null,
|
null,
|
||||||
'review',
|
'review',
|
||||||
@@ -91,31 +84,42 @@ class PRAutoBlogger_Chief_Editor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build system prompt for editorial review.
|
* @param string $niche
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
*/
|
*/
|
||||||
private function build_system_prompt( string $niche ): string {
|
private function build_system_prompt( string $niche ): string {
|
||||||
$prompt = 'You are a senior blog editor';
|
$prompt = 'You are a senior blog editor';
|
||||||
if ( '' !== $niche ) {
|
if ( '' !== $niche ) {
|
||||||
$prompt .= " specializing in {$niche} content";
|
$prompt .= " specializing in {$niche} content";
|
||||||
}
|
}
|
||||||
$prompt .= ". Review article drafts before publication.\n\n";
|
$prompt .= ". Your job is to review article drafts before publication.\n\n";
|
||||||
|
|
||||||
$prompt .= "Evaluate on: QUALITY, ACCURACY, SEO, COMPLETENESS, READABILITY.\n";
|
$prompt .= "Evaluate the article on:\n";
|
||||||
$prompt .= "Respond with JSON: {\n";
|
$prompt .= "1. QUALITY: Is the writing clear, engaging, and well-structured?\n";
|
||||||
|
$prompt .= "2. ACCURACY: Does the content avoid making unsupported health/medical claims? Is the tone appropriately cautious for the topic?\n";
|
||||||
|
$prompt .= "3. SEO: Are headings properly structured? Are target keywords naturally used?\n";
|
||||||
|
$prompt .= "4. COMPLETENESS: Does it cover the key points? Does it have a proper intro and conclusion?\n";
|
||||||
|
$prompt .= "5. READABILITY: Is sentence variety good? Are paragraphs appropriate length?\n\n";
|
||||||
|
|
||||||
|
$prompt .= "You must respond with JSON in this format:\n";
|
||||||
|
$prompt .= "{\n";
|
||||||
$prompt .= ' "verdict": "approved" | "revised" | "rejected",' . "\n";
|
$prompt .= ' "verdict": "approved" | "revised" | "rejected",' . "\n";
|
||||||
$prompt .= ' "quality_score": 0.0-1.0,' . "\n";
|
$prompt .= ' "quality_score": 0.0-1.0,' . "\n";
|
||||||
$prompt .= ' "seo_score": 0.0-1.0,' . "\n";
|
$prompt .= ' "seo_score": 0.0-1.0,' . "\n";
|
||||||
$prompt .= ' "issues": ["issue1", "issue2"],' . "\n";
|
$prompt .= ' "issues": ["issue 1", "issue 2"],' . "\n";
|
||||||
$prompt .= ' "notes": "Editorial notes",' . "\n";
|
$prompt .= ' "notes": "Brief editorial notes",' . "\n";
|
||||||
$prompt .= ' "revised_content": "Full revised HTML if revised, null otherwise"' . "\n";
|
$prompt .= ' "revised_content": "Full revised HTML if verdict is revised, null otherwise"' . "\n";
|
||||||
$prompt .= "}\n\n";
|
$prompt .= "}\n\n";
|
||||||
|
|
||||||
$prompt .= "Rules:\n";
|
$prompt .= "Rules:\n";
|
||||||
$prompt .= "- APPROVE if quality_score >= 0.7 and seo_score >= 0.6\n";
|
$prompt .= "- APPROVE if quality_score >= 0.7 and seo_score >= 0.6 and no critical issues\n";
|
||||||
$prompt .= "- REVISE if fixable issues exist — provide full revised HTML\n";
|
$prompt .= "- REVISE if fixable issues exist — you MUST provide the full revised HTML\n";
|
||||||
$prompt .= "- REJECT if fundamentally flawed\n";
|
$prompt .= "- REJECT only if the content is fundamentally flawed or off-topic\n";
|
||||||
$prompt .= "- Preserve formatting, links, lists when revising\n";
|
$prompt .= "- When revising, fix the issues yourself. Don't just describe them.\n";
|
||||||
|
$prompt .= "- When revising, PRESERVE all bullet points, numbered lists, hyperlinks, and structural formatting from the original. Do NOT flatten lists into prose or remove links.\n";
|
||||||
|
|
||||||
|
// Append user-defined editor instructions if configured.
|
||||||
$instructions = trim( (string) get_option( 'prautoblogger_editor_instructions', '' ) );
|
$instructions = trim( (string) get_option( 'prautoblogger_editor_instructions', '' ) );
|
||||||
if ( '' !== $instructions ) {
|
if ( '' !== $instructions ) {
|
||||||
$prompt .= "\nAdditional instructions:\n" . $instructions . "\n";
|
$prompt .= "\nAdditional instructions:\n" . $instructions . "\n";
|
||||||
@@ -125,15 +129,21 @@ class PRAutoBlogger_Chief_Editor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build user prompt for editorial review.
|
* @param string $content
|
||||||
|
* @param PRAutoBlogger_Article_Idea $idea
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
*/
|
*/
|
||||||
private function build_review_prompt( string $content, PRAutoBlogger_Article_Idea $idea ): string {
|
private function build_review_prompt( string $content, PRAutoBlogger_Article_Idea $idea ): string {
|
||||||
return sprintf(
|
return sprintf(
|
||||||
"Review this article draft:\n\n" .
|
"Review this article draft:\n\n" .
|
||||||
"BRIEF: Title: %s | Topic: %s | Type: %s\n" .
|
"ORIGINAL BRIEF:\n" .
|
||||||
"KEY POINTS: %s\n" .
|
"- Title: %s\n" .
|
||||||
"TARGET KEYWORDS: %s\n\n" .
|
"- Topic: %s\n" .
|
||||||
"CONTENT:\n%s",
|
"- Type: %s\n" .
|
||||||
|
"- Key points: %s\n" .
|
||||||
|
"- Target keywords: %s\n\n" .
|
||||||
|
"ARTICLE CONTENT:\n%s",
|
||||||
$idea->get_suggested_title(),
|
$idea->get_suggested_title(),
|
||||||
$idea->get_topic(),
|
$idea->get_topic(),
|
||||||
$idea->get_article_type(),
|
$idea->get_article_type(),
|
||||||
@@ -144,20 +154,25 @@ class PRAutoBlogger_Chief_Editor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse LLM editorial review response.
|
* Parse the LLM's editorial review response.
|
||||||
|
*
|
||||||
|
* @param string $content JSON response from LLM.
|
||||||
|
*
|
||||||
|
* @return PRAutoBlogger_Editorial_Review
|
||||||
*/
|
*/
|
||||||
private function parse_review_response( string $content ): PRAutoBlogger_Editorial_Review {
|
private function parse_review_response( string $content ): PRAutoBlogger_Editorial_Review {
|
||||||
$data = PRAutoBlogger_Json_Extractor::decode( $content );
|
$data = PRAutoBlogger_Json_Extractor::decode( $content );
|
||||||
|
|
||||||
if ( ! is_array( $data ) || ! isset( $data['verdict'] ) ) {
|
if ( ! is_array( $data ) || ! isset( $data['verdict'] ) ) {
|
||||||
PRAutoBlogger_Logger::instance()->error(
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
'Chief editor response unparseable (first 200 chars): ' . mb_substr( $content, 0, 200 ),
|
'Chief editor response was not valid JSON. Defaulting to rejected. Raw (first 500 chars): '
|
||||||
|
. mb_substr( $content, 0, 500 ),
|
||||||
'editor'
|
'editor'
|
||||||
);
|
);
|
||||||
return new PRAutoBlogger_Editorial_Review(
|
return new PRAutoBlogger_Editorial_Review(
|
||||||
array(
|
array(
|
||||||
'verdict' => 'rejected',
|
'verdict' => 'rejected',
|
||||||
'notes' => 'Editor response unparseable',
|
'notes' => 'Editor response could not be parsed.',
|
||||||
'quality_score' => 0.0,
|
'quality_score' => 0.0,
|
||||||
'seo_score' => 0.0,
|
'seo_score' => 0.0,
|
||||||
'issues' => array( 'Unparseable editor response' ),
|
'issues' => array( 'Unparseable editor response' ),
|
||||||
|
|||||||
@@ -2,13 +2,21 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
* Writer agent — generates blog post content from article ideas.
|
* Writer agent — generates blog post content from article ideas.
|
||||||
*
|
*
|
||||||
* Supports: single_pass (one LLM call) or multi_step (outline → draft → polish).
|
* Supports two configurable pipeline modes:
|
||||||
* Opik instrumentation: spans per LLM call when feature-flag enabled.
|
* - single_pass: One LLM call produces the complete article (cheaper, faster).
|
||||||
|
* - multi_step: Outline → Draft → Polish pipeline (higher quality, more cost).
|
||||||
*
|
*
|
||||||
* Triggered by: PRAutoBlogger_Article_Worker.
|
* Triggered by: PRAutoBlogger::run_generation_pipeline() (step 4).
|
||||||
* Dependencies: LLM_Provider_Interface, Cost_Tracker, Content_Prompts.
|
* Dependencies: LLM_Provider_Interface, Cost_Tracker, Content_Prompts.
|
||||||
|
*
|
||||||
|
* @see core/class-content-prompts.php — Builds all LLM prompts used here.
|
||||||
|
* @see core/class-chief-editor.php — Reviews output of this class.
|
||||||
|
* @see providers/interface-llm-provider.php — LLM used for generation.
|
||||||
|
* @see ARCHITECTURE.md — Data flow step 4.
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger_Content_Generator {
|
class PRAutoBlogger_Content_Generator {
|
||||||
|
|
||||||
@@ -26,13 +34,14 @@ class PRAutoBlogger_Content_Generator {
|
|||||||
/**
|
/**
|
||||||
* Generate a blog post from an article idea.
|
* Generate a blog post from an article idea.
|
||||||
*
|
*
|
||||||
|
* Reads pipeline mode from settings and dispatches to the appropriate method.
|
||||||
|
* Side effects: LLM API calls (1-3 depending on mode), cost logging.
|
||||||
|
*
|
||||||
* @param PRAutoBlogger_Article_Idea $idea The scored idea to generate content for.
|
* @param PRAutoBlogger_Article_Idea $idea The scored idea to generate content for.
|
||||||
* @return string Generated HTML content.
|
* @return string Generated HTML content.
|
||||||
|
* @throws \RuntimeException On generation failure.
|
||||||
*/
|
*/
|
||||||
public function generate( PRAutoBlogger_Article_Idea $idea, bool $eval_mode = false ): string {
|
public function generate( PRAutoBlogger_Article_Idea $idea ): string {
|
||||||
// In eval mode, suppress publishing and image generation side effects.
|
|
||||||
define( 'PRAUTOBLOGGER_EVAL_MODE', $eval_mode );
|
|
||||||
|
|
||||||
$mode = get_option( 'prautoblogger_writing_pipeline', 'multi_step' );
|
$mode = get_option( 'prautoblogger_writing_pipeline', 'multi_step' );
|
||||||
|
|
||||||
$request = new PRAutoBlogger_Content_Request(
|
$request = new PRAutoBlogger_Content_Request(
|
||||||
@@ -46,26 +55,21 @@ class PRAutoBlogger_Content_Generator {
|
|||||||
get_option( 'prautoblogger_writing_instructions', '' )
|
get_option( 'prautoblogger_writing_instructions', '' )
|
||||||
);
|
);
|
||||||
|
|
||||||
return 'single_pass' === $mode
|
if ( 'single_pass' === $mode ) {
|
||||||
? $this->generate_single_pass( $request )
|
return $this->generate_single_pass( $request );
|
||||||
: $this->generate_multi_step( $request );
|
}
|
||||||
|
|
||||||
|
return $this->generate_multi_step( $request );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single-pass: one LLM call produces complete article.
|
* Single-pass generation: one LLM call produces the complete article.
|
||||||
|
*
|
||||||
|
* @param PRAutoBlogger_Content_Request $request Content request with settings.
|
||||||
|
* @return string Generated HTML content.
|
||||||
*/
|
*/
|
||||||
private function generate_single_pass( PRAutoBlogger_Content_Request $request ): string {
|
private function generate_single_pass( PRAutoBlogger_Content_Request $request ): string {
|
||||||
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
|
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
|
||||||
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
|
|
||||||
|
|
||||||
$span_id = $ctx->start_span(
|
|
||||||
array(
|
|
||||||
'name' => 'single_pass_generation',
|
|
||||||
'type' => 'llm',
|
|
||||||
'model' => $model,
|
|
||||||
'provider' => 'openrouter',
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$response = $this->llm->send_chat_completion(
|
$response = $this->llm->send_chat_completion(
|
||||||
array(
|
array(
|
||||||
@@ -85,17 +89,6 @@ class PRAutoBlogger_Content_Generator {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
$ctx->end_span(
|
|
||||||
$span_id,
|
|
||||||
array(
|
|
||||||
'usage' => array(
|
|
||||||
'prompt_tokens' => $response['prompt_tokens'],
|
|
||||||
'completion_tokens' => $response['completion_tokens'],
|
|
||||||
'total_tokens' => $response['prompt_tokens'] + $response['completion_tokens'],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->cost_tracker->log_api_call(
|
$this->cost_tracker->log_api_call(
|
||||||
null,
|
null,
|
||||||
'draft',
|
'draft',
|
||||||
@@ -108,9 +101,7 @@ class PRAutoBlogger_Content_Generator {
|
|||||||
return $response['content'];
|
return $response['content'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Multi-step generation: Outline → Draft → Polish. */
|
||||||
* Multi-step: outline → draft → polish.
|
|
||||||
*/
|
|
||||||
private function generate_multi_step( PRAutoBlogger_Content_Request $request ): string {
|
private function generate_multi_step( PRAutoBlogger_Content_Request $request ): string {
|
||||||
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
|
$model = get_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
|
||||||
|
|
||||||
@@ -121,26 +112,15 @@ class PRAutoBlogger_Content_Generator {
|
|||||||
return $polished;
|
return $polished;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Multi-step stage 1: Generate an article outline. */
|
||||||
* Stage 1: Generate article outline.
|
|
||||||
*/
|
|
||||||
private function stage_outline( PRAutoBlogger_Content_Request $request, string $model ): string {
|
private function stage_outline( PRAutoBlogger_Content_Request $request, string $model ): string {
|
||||||
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
|
$system = PRAutoBlogger_Content_Prompts::build_system( $request );
|
||||||
|
|
||||||
$span_id = $ctx->start_span(
|
|
||||||
array(
|
|
||||||
'name' => 'outline_generation',
|
|
||||||
'type' => 'llm',
|
|
||||||
'model' => $model,
|
|
||||||
'provider' => 'openrouter',
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$response = $this->llm->send_chat_completion(
|
$response = $this->llm->send_chat_completion(
|
||||||
array(
|
array(
|
||||||
array(
|
array(
|
||||||
'role' => 'system',
|
'role' => 'system',
|
||||||
'content' => PRAutoBlogger_Content_Prompts::build_system( $request ),
|
'content' => $system,
|
||||||
),
|
),
|
||||||
array(
|
array(
|
||||||
'role' => 'user',
|
'role' => 'user',
|
||||||
@@ -154,17 +134,6 @@ class PRAutoBlogger_Content_Generator {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
$ctx->end_span(
|
|
||||||
$span_id,
|
|
||||||
array(
|
|
||||||
'usage' => array(
|
|
||||||
'prompt_tokens' => $response['prompt_tokens'],
|
|
||||||
'completion_tokens' => $response['completion_tokens'],
|
|
||||||
'total_tokens' => $response['prompt_tokens'] + $response['completion_tokens'],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->cost_tracker->log_api_call(
|
$this->cost_tracker->log_api_call(
|
||||||
null,
|
null,
|
||||||
'outline',
|
'outline',
|
||||||
@@ -177,26 +146,15 @@ class PRAutoBlogger_Content_Generator {
|
|||||||
return $response['content'];
|
return $response['content'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Multi-step stage 2: Write the full draft from the outline. */
|
||||||
* Stage 2: Write full draft from outline.
|
|
||||||
*/
|
|
||||||
private function stage_draft( PRAutoBlogger_Content_Request $request, string $outline, string $model ): string {
|
private function stage_draft( PRAutoBlogger_Content_Request $request, string $outline, string $model ): string {
|
||||||
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
|
$system = PRAutoBlogger_Content_Prompts::build_system( $request );
|
||||||
|
|
||||||
$span_id = $ctx->start_span(
|
|
||||||
array(
|
|
||||||
'name' => 'draft_generation',
|
|
||||||
'type' => 'llm',
|
|
||||||
'model' => $model,
|
|
||||||
'provider' => 'openrouter',
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$response = $this->llm->send_chat_completion(
|
$response = $this->llm->send_chat_completion(
|
||||||
array(
|
array(
|
||||||
array(
|
array(
|
||||||
'role' => 'system',
|
'role' => 'system',
|
||||||
'content' => PRAutoBlogger_Content_Prompts::build_system( $request ),
|
'content' => $system,
|
||||||
),
|
),
|
||||||
array(
|
array(
|
||||||
'role' => 'user',
|
'role' => 'user',
|
||||||
@@ -210,17 +168,6 @@ class PRAutoBlogger_Content_Generator {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
$ctx->end_span(
|
|
||||||
$span_id,
|
|
||||||
array(
|
|
||||||
'usage' => array(
|
|
||||||
'prompt_tokens' => $response['prompt_tokens'],
|
|
||||||
'completion_tokens' => $response['completion_tokens'],
|
|
||||||
'total_tokens' => $response['prompt_tokens'] + $response['completion_tokens'],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->cost_tracker->log_api_call(
|
$this->cost_tracker->log_api_call(
|
||||||
null,
|
null,
|
||||||
'draft',
|
'draft',
|
||||||
@@ -233,26 +180,15 @@ class PRAutoBlogger_Content_Generator {
|
|||||||
return $response['content'];
|
return $response['content'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Multi-step stage 3: Polish and refine the draft. */
|
||||||
* Stage 3: Polish and refine draft.
|
|
||||||
*/
|
|
||||||
private function stage_polish( PRAutoBlogger_Content_Request $request, string $draft, string $model ): string {
|
private function stage_polish( PRAutoBlogger_Content_Request $request, string $draft, string $model ): string {
|
||||||
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
|
$system = PRAutoBlogger_Content_Prompts::build_system( $request );
|
||||||
|
|
||||||
$span_id = $ctx->start_span(
|
|
||||||
array(
|
|
||||||
'name' => 'polish_generation',
|
|
||||||
'type' => 'llm',
|
|
||||||
'model' => $model,
|
|
||||||
'provider' => 'openrouter',
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$response = $this->llm->send_chat_completion(
|
$response = $this->llm->send_chat_completion(
|
||||||
array(
|
array(
|
||||||
array(
|
array(
|
||||||
'role' => 'system',
|
'role' => 'system',
|
||||||
'content' => PRAutoBlogger_Content_Prompts::build_system( $request ),
|
'content' => $system,
|
||||||
),
|
),
|
||||||
array(
|
array(
|
||||||
'role' => 'user',
|
'role' => 'user',
|
||||||
@@ -266,17 +202,6 @@ class PRAutoBlogger_Content_Generator {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
$ctx->end_span(
|
|
||||||
$span_id,
|
|
||||||
array(
|
|
||||||
'usage' => array(
|
|
||||||
'prompt_tokens' => $response['prompt_tokens'],
|
|
||||||
'completion_tokens' => $response['completion_tokens'],
|
|
||||||
'total_tokens' => $response['prompt_tokens'] + $response['completion_tokens'],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->cost_tracker->log_api_call(
|
$this->cost_tracker->log_api_call(
|
||||||
null,
|
null,
|
||||||
'polish',
|
'polish',
|
||||||
@@ -288,14 +213,4 @@ class PRAutoBlogger_Content_Generator {
|
|||||||
|
|
||||||
return $response['content'];
|
return $response['content'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate content in eval mode (no publishing, no image generation).
|
|
||||||
*
|
|
||||||
* @param PRAutoBlogger_Article_Idea $idea The scored idea to generate content for.
|
|
||||||
* @return string Generated HTML content.
|
|
||||||
*/
|
|
||||||
public function generate_eval( PRAutoBlogger_Article_Idea $idea ): string {
|
|
||||||
return $this->generate( $idea, true );
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,19 +235,19 @@ class PRAutoBlogger_Cost_Tracker {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$cutoff_datetime = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );
|
$cutoff_time = time() - ( $days * DAY_IN_SECONDS );
|
||||||
$stage_list = implode( ',', array_map( array( $wpdb, 'prepare' ), array_fill( 0, count( $stages ), '%s' ), $stages ) );
|
$stage_list = implode( ',', array_map( array( $wpdb, 'prepare' ), array_fill( 0, count( $stages ), '%s' ), $stages ) );
|
||||||
$table_name = $wpdb->prefix . 'prautoblogger_generation_log';
|
$table_name = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||||
|
|
||||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Prepared stage list via array_map + prepare
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Prepared stage list via array_map + prepare
|
||||||
$query = $wpdb->prepare(
|
$query = $wpdb->prepare(
|
||||||
"SELECT AVG(prompt_tokens) as avg_prompt,
|
"SELECT AVG(input_tokens) as avg_input,
|
||||||
AVG(completion_tokens) as avg_completion,
|
AVG(output_tokens) as avg_output,
|
||||||
COUNT(*) as sample_count
|
COUNT(*) as sample_count
|
||||||
FROM $table_name
|
FROM $table_name
|
||||||
WHERE stage IN ( $stage_list )
|
WHERE stage IN ( $stage_list )
|
||||||
AND created_at >= %s",
|
AND timestamp >= %d",
|
||||||
$cutoff_datetime
|
$cutoff_time
|
||||||
);
|
);
|
||||||
|
|
||||||
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- phpcs pragma above
|
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- phpcs pragma above
|
||||||
@@ -262,8 +262,8 @@ class PRAutoBlogger_Cost_Tracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return array(
|
return array(
|
||||||
'avg_prompt_tokens' => (float) ( $result['avg_prompt'] ?? 0 ),
|
'avg_prompt_tokens' => (float) ( $result['avg_input'] ?? 0 ),
|
||||||
'avg_completion_tokens' => (float) ( $result['avg_completion'] ?? 0 ),
|
'avg_completion_tokens' => (float) ( $result['avg_output'] ?? 0 ),
|
||||||
'sample_size' => (int) ( $result['sample_count'] ?? 0 ),
|
'sample_size' => (int) ( $result['sample_count'] ?? 0 ),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,14 +47,12 @@ class PRAutoBlogger_Image_Pipeline {
|
|||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
?PRAutoBlogger_Image_Provider_Interface $provider = null,
|
?PRAutoBlogger_Image_Provider_Interface $provider = null,
|
||||||
?PRAutoBlogger_Cost_Tracker $cost_tracker = null,
|
?PRAutoBlogger_Cost_Tracker $cost_tracker = null
|
||||||
?PRAutoBlogger_Opik_Trace_Context $trace_context = null
|
|
||||||
) {
|
) {
|
||||||
$this->provider = $provider ?? self::create_default_provider();
|
$this->provider = $provider ?? self::create_default_provider();
|
||||||
$this->prompt_builder = new PRAutoBlogger_Image_Prompt_Builder( $trace_context );
|
$this->prompt_builder = new PRAutoBlogger_Image_Prompt_Builder();
|
||||||
$this->sideloader = new PRAutoBlogger_Image_Media_Sideloader();
|
$this->sideloader = new PRAutoBlogger_Image_Media_Sideloader();
|
||||||
$this->cost_tracker = $cost_tracker ?? new PRAutoBlogger_Cost_Tracker();
|
$this->cost_tracker = $cost_tracker ?? new PRAutoBlogger_Cost_Tracker();
|
||||||
$this->trace_context = $trace_context;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,16 +6,13 @@ declare(strict_types=1);
|
|||||||
*
|
*
|
||||||
* Builds image prompts from article content or source data.
|
* Builds image prompts from article content or source data.
|
||||||
*
|
*
|
||||||
* Uses a cheap LLM (via OpenRouter) to distill article content into a concise
|
* Uses a cheap LLM (via OpenRouter) to distill article content into concise
|
||||||
* 1-2 sentence editorial topic/mechanism summary, then substitutes that summary
|
* visual scene descriptions optimised for image generation. Falls back to rule-based
|
||||||
* into the admin-editable editorial style template (the {{ topic_summary }}
|
|
||||||
* slot) to produce a text-free image-gen prompt. Falls back to rule-based
|
|
||||||
* synthesis if the LLM call fails, so image generation never blocks on a
|
* synthesis if the LLM call fails, so image generation never blocks on a
|
||||||
* prompt-rewriting outage.
|
* prompt-rewriting outage.
|
||||||
*
|
*
|
||||||
* Triggered by: PRAutoBlogger_Image_Pipeline during content generation run.
|
* Triggered by: PRAutoBlogger_Image_Pipeline during content generation run.
|
||||||
* Dependencies: PRAutoBlogger_OpenRouter_Provider (LLM call),
|
* Dependencies: PRAutoBlogger_OpenRouter_Provider (LLM call),
|
||||||
* PRAutoBlogger_Image_Template_Filler (template substitution),
|
|
||||||
* PRAutoBlogger_Cost_Tracker (logs prompt-rewrite cost),
|
* PRAutoBlogger_Cost_Tracker (logs prompt-rewrite cost),
|
||||||
* PRAutoBlogger_Logger (diagnostics).
|
* PRAutoBlogger_Logger (diagnostics).
|
||||||
*
|
*
|
||||||
@@ -30,39 +27,34 @@ class PRAutoBlogger_Image_Prompt_Builder {
|
|||||||
* prompts. Exposed as a `public const` so the admin-settings layer can
|
* prompts. Exposed as a `public const` so the admin-settings layer can
|
||||||
* use it as the default value for `prautoblogger_image_prompt_instructions`.
|
* use it as the default value for `prautoblogger_image_prompt_instructions`.
|
||||||
* The option, when non-empty, wins at call time (see rewrite_via_llm).
|
* The option, when non-empty, wins at call time (see rewrite_via_llm).
|
||||||
*
|
|
||||||
* Editorial pivot (v0.16.0): the model emits a concise topic/mechanism
|
|
||||||
* summary as the SCENE — concrete, single centered focal subject, no text,
|
|
||||||
* no people-as-gag, no logos — which the prompt builder substitutes into
|
|
||||||
* the editorial style template's {{ topic_summary }} slot. The CAPTION line
|
|
||||||
* is preserved unchanged for the existing HTML-caption-below-image path.
|
|
||||||
*/
|
*/
|
||||||
public const REWRITER_SYSTEM_PROMPT = <<<'PROMPT'
|
public const REWRITER_SYSTEM_PROMPT = <<<'PROMPT'
|
||||||
You write subject descriptions for a text-free editorial science illustration about peptides, supplements, or biohacking.
|
You are a comedy writer and single-panel cartoon creator, like Gary Larson (The Far Side) meets science humor.
|
||||||
|
|
||||||
Given an article title and summary, output TWO parts separated by a blank line:
|
Given an article title and summary about peptides, supplements, or biohacking, create a SINGLE-PANEL COMIC concept. Output TWO parts separated by a blank line:
|
||||||
|
|
||||||
SCENE: A concise 1-2 sentence description of the article's core topic or mechanism, written as the SUBJECT of a single editorial illustration. Name ONE concrete, centered focal subject and the few supporting visual elements around it (e.g. a labelled-free vial and dropper, a stylised receptor on a cell membrane, a molecular chain, a dosing syringe, an organ cross-section). Favor a clear central focal point with margin around it so the image crops well. This is image-gen direction, not prose.
|
SCENE: One sentence describing the visual gag — a funny, absurd, or ironic situation related to the article's topic. Favor CONCRETE, HUMAN-SCALE scenes: people reacting to things, doctors and patients, scientists in a lab, gym bros with peptide vials, partners at a kitchen table, ordinary objects in ordinary rooms. Keep it simple — one clear visual joke, 1-3 characters max.
|
||||||
|
|
||||||
CAPTION: A short, informative caption line (under 15 words) for display as HTML text below the image. Plain and editorial, not a joke.
|
CAPTION: A short, punchy caption or speech bubble line (under 15 words) that delivers the punchline. The humor should be dry, nerdy, and accessible — the reader should chuckle even if they only half-understand the science.
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- The SCENE must depict the article's actual subject/mechanism, not a generic science scene.
|
- The joke MUST relate to the article's actual subject matter, not generic science humor
|
||||||
- NO text, words, captions, speech bubbles, labels, or logos described in the SCENE — the illustration is text-free; text is rendered separately.
|
- DO NOT personify molecules, peptides, hormones, proteins, or other chemical structures (no "anthropomorphic molecule", no "cartoon hormone"). Image models render anthropomorphized chemistry as incoherent blobs. If the article is about a substance, show a PERSON reacting to or using the substance; if the article is abstract (a mechanism, a process, a concept), fall back on a human observer reacting to its effect.
|
||||||
- NO people used as a visual gag and NO cartoon/comic framing. A human anatomical element (a cell, an organ, a hand holding a vial) is fine if it is the legitimate subject.
|
- Characters can have simple cartoon faces (round heads, dot eyes, expressive eyebrows)
|
||||||
- Do NOT personify molecules, peptides, hormones, or proteins (no "smiling molecule"); depict them as clean stylised diagrams or structures.
|
- Keep the scene physically simple — few objects, clear staging, easy to read at thumbnail size
|
||||||
- Keep the staging simple with one clear focal subject, readable at small sizes.
|
- The caption should work as a standalone joke even without the image
|
||||||
|
- No logos, watermarks, or branding
|
||||||
- Output ONLY the scene and caption. No preamble, no explanation.
|
- Output ONLY the scene and caption. No preamble, no explanation.
|
||||||
|
|
||||||
Example output format:
|
Example output format:
|
||||||
A single glass peptide vial on a clean surface with a fine dropper above it, a faint stylised molecular chain arcing behind as a backdrop, centered with generous negative space.
|
A muscular gym bro stares, deflated, at a tiny glass vial in his hand while his friend squats a barbell effortlessly in the background.
|
||||||
|
|
||||||
A concentrated look at how the compound is prepared before use.
|
"They told me the peptide did the work, not the squats."
|
||||||
PROMPT;
|
PROMPT;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Max tokens for the rewriter response. Enough room for the 1-2 sentence
|
* Max tokens for the rewriter response. Comic concepts need more room
|
||||||
* topic/mechanism summary plus the short editorial caption line.
|
* for the scene description plus the caption punchline.
|
||||||
*/
|
*/
|
||||||
private const REWRITER_MAX_TOKENS = 180;
|
private const REWRITER_MAX_TOKENS = 180;
|
||||||
|
|
||||||
@@ -73,29 +65,12 @@ PROMPT;
|
|||||||
*/
|
*/
|
||||||
private ?PRAutoBlogger_OpenRouter_Provider $llm = null;
|
private ?PRAutoBlogger_OpenRouter_Provider $llm = null;
|
||||||
|
|
||||||
/**
|
|
||||||
* Opik trace context for instrumentation.
|
|
||||||
*
|
|
||||||
* @var PRAutoBlogger_Opik_Trace_Context|null
|
|
||||||
*/
|
|
||||||
private ?PRAutoBlogger_Opik_Trace_Context $trace_context = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constructor.
|
|
||||||
*
|
|
||||||
* @param PRAutoBlogger_Opik_Trace_Context|null $trace_context Optional trace context for instrumentation.
|
|
||||||
*/
|
|
||||||
public function __construct( ?PRAutoBlogger_Opik_Trace_Context $trace_context = null ) {
|
|
||||||
$this->trace_context = $trace_context;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a visual prompt from finished article content.
|
* Build a visual prompt from finished article content.
|
||||||
*
|
*
|
||||||
* Tries LLM rewriting first; falls back to rule-based synthesis on
|
* Tries LLM rewriting first; falls back to rule-based synthesis on
|
||||||
* failure. Splits scene (the topic summary, for image gen) from caption
|
* failure. Splits scene (for image gen) from caption (HTML below the
|
||||||
* (HTML below the image) and substitutes the scene into the editorial
|
* image) and appends the style suffix.
|
||||||
* style template's {{ topic_summary }} slot.
|
|
||||||
*
|
*
|
||||||
* @param array{post_title?: string, post_content?: string, suggested_title?: string} $article_data
|
* @param array{post_title?: string, post_content?: string, suggested_title?: string} $article_data
|
||||||
* @return array{prompt: string, caption: string}
|
* @return array{prompt: string, caption: string}
|
||||||
@@ -103,12 +78,14 @@ PROMPT;
|
|||||||
public function build_article_prompt( array $article_data ): array {
|
public function build_article_prompt( array $article_data ): array {
|
||||||
$title = $article_data['post_title'] ?? $article_data['suggested_title'] ?? 'Product';
|
$title = $article_data['post_title'] ?? $article_data['suggested_title'] ?? 'Product';
|
||||||
$content = $article_data['post_content'] ?? '';
|
$content = $article_data['post_content'] ?? '';
|
||||||
$first_para = PRAutoBlogger_Image_Scene_Parser::extract_first_paragraph( $content );
|
$first_para = $this->extract_first_paragraph( $content );
|
||||||
|
|
||||||
$parsed = $this->rewrite_via_llm( $title, $first_para );
|
$parsed = $this->rewrite_via_llm( $title, $first_para );
|
||||||
|
|
||||||
|
$style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
||||||
|
|
||||||
return array(
|
return array(
|
||||||
'prompt' => PRAutoBlogger_Image_Template_Filler::fill( $parsed['scene'] ),
|
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
|
||||||
'caption' => $parsed['caption'],
|
'caption' => $parsed['caption'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -127,8 +104,10 @@ PROMPT;
|
|||||||
|
|
||||||
$parsed = $this->rewrite_via_llm( $title, $context );
|
$parsed = $this->rewrite_via_llm( $title, $context );
|
||||||
|
|
||||||
|
$style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
||||||
|
|
||||||
return array(
|
return array(
|
||||||
'prompt' => PRAutoBlogger_Image_Template_Filler::fill( $parsed['scene'] ),
|
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
|
||||||
'caption' => $parsed['caption'],
|
'caption' => $parsed['caption'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -163,24 +142,7 @@ PROMPT;
|
|||||||
$user_message .= "\n\nSummary: {$context}";
|
$user_message .= "\n\nSummary: {$context}";
|
||||||
}
|
}
|
||||||
|
|
||||||
$span_id = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if ( null !== $this->trace_context ) {
|
|
||||||
$span_id = $this->trace_context->start_span(
|
|
||||||
array(
|
|
||||||
'name' => 'image_prompt_rewrite',
|
|
||||||
'type' => 'llm',
|
|
||||||
'model' => get_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL ),
|
|
||||||
'provider' => 'openrouter',
|
|
||||||
'input' => array(
|
|
||||||
'title' => $title,
|
|
||||||
'context' => substr( $context, 0, 200 ),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$llm = $this->get_llm_provider();
|
$llm = $this->get_llm_provider();
|
||||||
$model = get_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL );
|
$model = get_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL );
|
||||||
$system = $this->resolve_system_prompt();
|
$system = $this->resolve_system_prompt();
|
||||||
@@ -226,24 +188,8 @@ PROMPT;
|
|||||||
'image_prompt_builder'
|
'image_prompt_builder'
|
||||||
);
|
);
|
||||||
|
|
||||||
if ( null !== $span_id && null !== $this->trace_context ) {
|
|
||||||
$this->trace_context->end_span(
|
|
||||||
$span_id,
|
|
||||||
array(
|
|
||||||
'output' => array(
|
|
||||||
'raw_response' => substr( $raw, 0, 200 ),
|
|
||||||
),
|
|
||||||
'usage' => array(
|
|
||||||
'prompt_tokens' => $result['prompt_tokens'] ?? 0,
|
|
||||||
'completion_tokens' => $result['completion_tokens'] ?? 0,
|
|
||||||
'total_tokens' => ( $result['prompt_tokens'] ?? 0 ) + ( $result['completion_tokens'] ?? 0 ),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( '' !== $raw ) {
|
if ( '' !== $raw ) {
|
||||||
return PRAutoBlogger_Image_Scene_Parser::parse_scene_and_caption( $raw );
|
return $this->parse_scene_and_caption( $raw );
|
||||||
}
|
}
|
||||||
} catch ( \Throwable $e ) {
|
} catch ( \Throwable $e ) {
|
||||||
// LLM failure is not fatal — fall back to rule-based synthesis.
|
// LLM failure is not fatal — fall back to rule-based synthesis.
|
||||||
@@ -253,7 +199,64 @@ PROMPT;
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return PRAutoBlogger_Image_Scene_Parser::synthesize_visual_concepts_fallback( $title, $context );
|
return $this->synthesize_visual_concepts_fallback( $title, $context );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the LLM response into separate scene and caption parts.
|
||||||
|
*
|
||||||
|
* Expected format from the LLM:
|
||||||
|
* Scene description text here.
|
||||||
|
*
|
||||||
|
* "Caption punchline here."
|
||||||
|
*
|
||||||
|
* Falls back gracefully: if no blank-line separator is found, the entire
|
||||||
|
* response becomes the scene with an empty caption.
|
||||||
|
*
|
||||||
|
* @param string $raw Raw LLM response text.
|
||||||
|
* @return array{scene: string, caption: string}
|
||||||
|
*/
|
||||||
|
private function parse_scene_and_caption( string $raw ): array {
|
||||||
|
// Split on blank line (the format the system prompt requests).
|
||||||
|
$parts = preg_split( '/\n\s*\n/', $raw, 2 );
|
||||||
|
|
||||||
|
if ( count( $parts ) >= 2 ) {
|
||||||
|
$scene = trim( $parts[0] );
|
||||||
|
$caption = trim( $parts[1] );
|
||||||
|
// Strip surrounding quotes from caption — the LLM wraps it in quotes.
|
||||||
|
$caption = trim( $caption, '"\'' );
|
||||||
|
return array(
|
||||||
|
'scene' => $scene,
|
||||||
|
'caption' => $caption,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No blank-line separator found — treat entire response as scene.
|
||||||
|
return array(
|
||||||
|
'scene' => trim( $raw ),
|
||||||
|
'caption' => '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the first paragraph from HTML content.
|
||||||
|
*
|
||||||
|
* Removes all tags and captures the text up to the first double-newline
|
||||||
|
* or 200 characters, whichever comes first.
|
||||||
|
*
|
||||||
|
* @param string $html HTML content.
|
||||||
|
* @return string Plain text of the first paragraph.
|
||||||
|
*/
|
||||||
|
private function extract_first_paragraph( string $html ): string {
|
||||||
|
$text = wp_strip_all_tags( $html );
|
||||||
|
$paras = preg_split( '/\n\n+/', $text );
|
||||||
|
$para = isset( $paras[0] ) ? trim( $paras[0] ) : '';
|
||||||
|
|
||||||
|
if ( strlen( $para ) > 200 ) {
|
||||||
|
$para = substr( $para, 0, 200 ) . '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $para;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -265,13 +268,36 @@ PROMPT;
|
|||||||
* @return array{prompt: string, caption: string}
|
* @return array{prompt: string, caption: string}
|
||||||
*/
|
*/
|
||||||
public function build_fallback_prompt( string $title ): array {
|
public function build_fallback_prompt( string $title ): array {
|
||||||
$parsed = PRAutoBlogger_Image_Scene_Parser::synthesize_visual_concepts_fallback( $title, '' );
|
$parsed = $this->synthesize_visual_concepts_fallback( $title, '' );
|
||||||
|
$style_suffix = get_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
||||||
return array(
|
return array(
|
||||||
'prompt' => PRAutoBlogger_Image_Template_Filler::fill( $parsed['scene'] ),
|
'prompt' => trim( $parsed['scene'] . ' ' . $style_suffix ),
|
||||||
'caption' => $parsed['caption'],
|
'caption' => $parsed['caption'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rule-based fallback when LLM rewriting is unavailable.
|
||||||
|
*
|
||||||
|
* Kept deliberately simple — this only runs if OpenRouter is down.
|
||||||
|
*
|
||||||
|
* @param string $title Main heading / topic.
|
||||||
|
* @param string $supporting_text Additional context.
|
||||||
|
* @return array{scene: string, caption: string} Scene for image gen, caption for HTML.
|
||||||
|
*/
|
||||||
|
private function synthesize_visual_concepts_fallback( string $title, string $supporting_text ): array {
|
||||||
|
// Fallback produces a simple comic concept when the LLM is unavailable.
|
||||||
|
$scene = sprintf(
|
||||||
|
'A cartoon scientist in a lab coat looking bewildered while examining something related to: %s.',
|
||||||
|
$title
|
||||||
|
);
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'scene' => trim( $scene ),
|
||||||
|
'caption' => 'Science is full of surprises.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Lazy-load the OpenRouter provider. */
|
/** Lazy-load the OpenRouter provider. */
|
||||||
private function get_llm_provider(): PRAutoBlogger_OpenRouter_Provider {
|
private function get_llm_provider(): PRAutoBlogger_OpenRouter_Provider {
|
||||||
if ( null === $this->llm ) {
|
if ( null === $this->llm ) {
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses LLM output into scene/caption pairs and synthesises fallback prompts
|
|
||||||
* from article titles when the LLM call fails.
|
|
||||||
*
|
|
||||||
* Who calls it: PRAutoBlogger_Image_Prompt_Builder (static calls).
|
|
||||||
* Dependencies: None.
|
|
||||||
*/
|
|
||||||
class PRAutoBlogger_Image_Scene_Parser {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse the LLM response into separate scene and caption parts.
|
|
||||||
*
|
|
||||||
* Expected format from the LLM:
|
|
||||||
* Scene description text here.
|
|
||||||
*
|
|
||||||
* "Caption punchline here."
|
|
||||||
*
|
|
||||||
* Falls back gracefully: if no blank-line separator is found, the entire
|
|
||||||
* response becomes the scene with an empty caption.
|
|
||||||
*
|
|
||||||
* @param string $raw Raw LLM response text.
|
|
||||||
* @return array{scene: string, caption: string}
|
|
||||||
*/
|
|
||||||
public static function parse_scene_and_caption( string $raw ): array {
|
|
||||||
// Split on blank line (the format the system prompt requests).
|
|
||||||
$parts = preg_split( '/\n\s*\n/', $raw, 2 );
|
|
||||||
|
|
||||||
if ( count( $parts ) >= 2 ) {
|
|
||||||
$scene = trim( $parts[0] );
|
|
||||||
$caption = trim( $parts[1] );
|
|
||||||
// Strip surrounding quotes from caption — the LLM wraps it in quotes.
|
|
||||||
$caption = trim( $caption, '"\'' );
|
|
||||||
return array(
|
|
||||||
'scene' => $scene,
|
|
||||||
'caption' => $caption,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// No blank-line separator found — treat entire response as scene.
|
|
||||||
return array(
|
|
||||||
'scene' => trim( $raw ),
|
|
||||||
'caption' => '',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the first paragraph from HTML content.
|
|
||||||
*
|
|
||||||
* Removes all tags and captures the text up to the first double-newline
|
|
||||||
* or 200 characters, whichever comes first.
|
|
||||||
*
|
|
||||||
* @param string $html HTML content.
|
|
||||||
* @return string Plain text of the first paragraph.
|
|
||||||
*/
|
|
||||||
public static function extract_first_paragraph( string $html ): string {
|
|
||||||
$text = wp_strip_all_tags( $html );
|
|
||||||
$paras = preg_split( '/\n\n+/', $text );
|
|
||||||
$para = isset( $paras[0] ) ? trim( $paras[0] ) : '';
|
|
||||||
|
|
||||||
if ( strlen( $para ) > 200 ) {
|
|
||||||
$para = substr( $para, 0, 200 ) . '...';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $para;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rule-based fallback when LLM rewriting is unavailable.
|
|
||||||
*
|
|
||||||
* Kept deliberately simple — this only runs if OpenRouter is down. Produces
|
|
||||||
* a text-free editorial-illustration subject (not a comic) so the prompt
|
|
||||||
* builder always has a non-empty topic summary to substitute into the
|
|
||||||
* style template, never shipping a blank prompt to the provider (brief A6).
|
|
||||||
*
|
|
||||||
* @param string $title Main heading / topic.
|
|
||||||
* @param string $supporting_text Additional context.
|
|
||||||
* @return array{scene: string, caption: string} Scene for image gen, caption for HTML.
|
|
||||||
*/
|
|
||||||
public static function synthesize_visual_concepts_fallback( string $title, string $supporting_text ): array {
|
|
||||||
// Fallback produces a simple editorial-illustration subject when the LLM is unavailable.
|
|
||||||
$scene = sprintf(
|
|
||||||
'A clean, centered editorial science illustration representing the topic of %s, with a single clear focal subject and generous negative space.',
|
|
||||||
$title
|
|
||||||
);
|
|
||||||
|
|
||||||
return array(
|
|
||||||
'scene' => trim( $scene ),
|
|
||||||
'caption' => '',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
|
||||||
*
|
|
||||||
* Fills the admin-editable editorial image-prompt template with the
|
|
||||||
* rewriter-produced topic summary, replacing the old "scene + style suffix"
|
|
||||||
* concatenation.
|
|
||||||
*
|
|
||||||
* The template (option `prautoblogger_image_style_template`, default
|
|
||||||
* PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE) contains exactly one
|
|
||||||
* `{{ topic_summary }}` token. This class:
|
|
||||||
* - resolves the active template (admin override wins; blank → default),
|
|
||||||
* - sanitises and length-clamps the topic summary (brief A5),
|
|
||||||
* - substitutes the token, and
|
|
||||||
* - degrades safely (brief A5/A6) so a blank or token-only prompt is never
|
|
||||||
* emitted to the image provider.
|
|
||||||
*
|
|
||||||
* Who calls it: PRAutoBlogger_Image_Prompt_Builder (static call from each of
|
|
||||||
* build_article_prompt / build_source_prompt / build_fallback_prompt).
|
|
||||||
* Dependencies: PRAutoBlogger_Logger (diagnostics only).
|
|
||||||
*
|
|
||||||
* @see core/class-image-prompt-builder.php — Sole caller.
|
|
||||||
* @see ARCHITECTURE.md — Image generation data flow.
|
|
||||||
*/
|
|
||||||
class PRAutoBlogger_Image_Template_Filler {
|
|
||||||
|
|
||||||
/** The single substitution token the template must contain. */
|
|
||||||
public const TOKEN = '{{ topic_summary }}';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Max characters retained from the rewriter topic summary before it is
|
|
||||||
* substituted into the template. Keeps the final provider prompt bounded
|
|
||||||
* even if the rewriter ignores its length guidance.
|
|
||||||
*/
|
|
||||||
public const MAX_SUMMARY_CHARS = 320;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the final image-generation prompt from a rewriter scene.
|
|
||||||
*
|
|
||||||
* @param string $scene Raw topic/mechanism summary from the rewriter (the
|
|
||||||
* "scene" half of the scene/caption contract).
|
|
||||||
* @return string Provider-ready prompt; never empty.
|
|
||||||
*/
|
|
||||||
public static function fill( string $scene ): string {
|
|
||||||
$template = self::resolve_template();
|
|
||||||
$summary = self::sanitize_summary( $scene );
|
|
||||||
|
|
||||||
// Brief A6: never ship a blank or token-only prompt. If the rewriter
|
|
||||||
// produced nothing usable, return the template with the token removed
|
|
||||||
// only as a last resort — but the caller guards this by passing a
|
|
||||||
// rule-based fallback scene, so an empty summary here is unexpected.
|
|
||||||
if ( '' === $summary ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->warning(
|
|
||||||
'Editorial prompt fill received an empty topic summary; emitting style-only prompt.',
|
|
||||||
'image_template_filler'
|
|
||||||
);
|
|
||||||
return self::strip_token( $template );
|
|
||||||
}
|
|
||||||
|
|
||||||
$token_count = substr_count( $template, self::TOKEN );
|
|
||||||
|
|
||||||
if ( 1 === $token_count ) {
|
|
||||||
return trim( str_replace( self::TOKEN, $summary, $template ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Brief A5: admin removed or duplicated the token. Fall back to
|
|
||||||
// appending the summary to the style text and log a warning rather
|
|
||||||
// than shipping a malformed prompt.
|
|
||||||
PRAutoBlogger_Logger::instance()->warning(
|
|
||||||
sprintf(
|
|
||||||
'Image style template has %d "%s" tokens (expected 1); appending summary instead of substituting.',
|
|
||||||
$token_count,
|
|
||||||
self::TOKEN
|
|
||||||
),
|
|
||||||
'image_template_filler'
|
|
||||||
);
|
|
||||||
|
|
||||||
$base = self::strip_token( $template );
|
|
||||||
return trim( $base . ' ' . $summary );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitise and validate the admin-submitted style template on save.
|
|
||||||
*
|
|
||||||
* Multi-line, so sanitised with sanitize_textarea_field() (not
|
|
||||||
* sanitize_text_field(), which would strip newlines). Per brief A5 the
|
|
||||||
* template must contain exactly one TOKEN; otherwise the previous value is
|
|
||||||
* kept and a settings error is surfaced. A blank submission falls back to
|
|
||||||
* the editorial default so image generation can never be bricked.
|
|
||||||
*
|
|
||||||
* @param string $value Submitted template.
|
|
||||||
* @return string Sanitised, validated template.
|
|
||||||
*/
|
|
||||||
public static function sanitize_for_save( string $value ): string {
|
|
||||||
$clean = sanitize_textarea_field( $value );
|
|
||||||
|
|
||||||
if ( '' === trim( $clean ) ) {
|
|
||||||
return PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$token_count = substr_count( $clean, self::TOKEN );
|
|
||||||
if ( 1 !== $token_count ) {
|
|
||||||
add_settings_error(
|
|
||||||
'prautoblogger_image_style_template',
|
|
||||||
'prautoblogger_image_style_template_token',
|
|
||||||
sprintf(
|
|
||||||
/* translators: 1: required token, 2: number of tokens found. */
|
|
||||||
esc_html__( 'The Style Template must contain exactly one %1$s token (found %2$d). Keeping the previous value.', 'prautoblogger' ),
|
|
||||||
esc_html( self::TOKEN ),
|
|
||||||
(int) $token_count
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return (string) get_option( 'prautoblogger_image_style_template', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE );
|
|
||||||
}
|
|
||||||
|
|
||||||
return $clean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the active template: admin override wins; blank → default.
|
|
||||||
*
|
|
||||||
* @return string Non-empty template string.
|
|
||||||
*/
|
|
||||||
private static function resolve_template(): string {
|
|
||||||
$override = (string) get_option( 'prautoblogger_image_style_template', '' );
|
|
||||||
if ( '' !== trim( $override ) ) {
|
|
||||||
return $override;
|
|
||||||
}
|
|
||||||
return PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Strip control characters and clamp length of the rewriter summary
|
|
||||||
* (brief A5). Whitespace runs (including newlines) are collapsed to single
|
|
||||||
* spaces so the summary sits cleanly inside the template.
|
|
||||||
*
|
|
||||||
* @param string $scene Raw rewriter scene.
|
|
||||||
* @return string Sanitised, length-clamped summary (possibly empty).
|
|
||||||
*/
|
|
||||||
private static function sanitize_summary( string $scene ): string {
|
|
||||||
// Remove control characters (C0/C1) except via whitespace collapse below.
|
|
||||||
$clean = preg_replace( '/[\x00-\x1F\x7F]+/u', ' ', $scene );
|
|
||||||
if ( null === $clean ) {
|
|
||||||
// preg_replace can return null on bad UTF-8; degrade conservatively.
|
|
||||||
$clean = preg_replace( '/[\x00-\x1F\x7F]+/', ' ', $scene );
|
|
||||||
$clean = is_string( $clean ) ? $clean : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collapse whitespace runs to single spaces.
|
|
||||||
$collapsed = preg_replace( '/\s+/u', ' ', $clean );
|
|
||||||
$clean = is_string( $collapsed ) ? $collapsed : $clean;
|
|
||||||
$clean = trim( $clean );
|
|
||||||
|
|
||||||
if ( '' === $clean ) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clamp length (multibyte-aware where available).
|
|
||||||
if ( function_exists( 'mb_strlen' ) ) {
|
|
||||||
if ( mb_strlen( $clean ) > self::MAX_SUMMARY_CHARS ) {
|
|
||||||
$clean = rtrim( mb_substr( $clean, 0, self::MAX_SUMMARY_CHARS ) ) . '...';
|
|
||||||
}
|
|
||||||
} elseif ( strlen( $clean ) > self::MAX_SUMMARY_CHARS ) {
|
|
||||||
$clean = rtrim( substr( $clean, 0, self::MAX_SUMMARY_CHARS ) ) . '...';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $clean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove every occurrence of the token from a template, tidying the
|
|
||||||
* leftover whitespace so the residual style text is still well-formed.
|
|
||||||
*
|
|
||||||
* @param string $template Template possibly containing the token.
|
|
||||||
* @return string Token-free, trimmed template.
|
|
||||||
*/
|
|
||||||
private static function strip_token( string $template ): string {
|
|
||||||
$stripped = str_replace( self::TOKEN, '', $template );
|
|
||||||
$stripped = preg_replace( '/[ \t]+/', ' ', $stripped );
|
|
||||||
$stripped = is_string( $stripped ) ? $stripped : $template;
|
|
||||||
return trim( $stripped );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -232,12 +232,11 @@ class PRAutoBlogger_Post_Assembler {
|
|||||||
* Generate and attach images to a published post.
|
* Generate and attach images to a published post.
|
||||||
* Non-blocking — logs errors but doesn't re-throw since the post is already created.
|
* Non-blocking — logs errors but doesn't re-throw since the post is already created.
|
||||||
*
|
*
|
||||||
* @param int $post_id Post ID.
|
* @param int $post_id Post ID.
|
||||||
* @param PRAutoBlogger_Article_Idea $idea Article idea (contains source IDs).
|
* @param PRAutoBlogger_Article_Idea $idea Article idea (contains source IDs).
|
||||||
* @param array $post_data Post data array (for article content).
|
* @param array $post_data Post data array (for article content).
|
||||||
* @param ?PRAutoBlogger_Cost_Tracker $cost_tracker Optional cost tracker for image generation cost logging.
|
|
||||||
*/
|
*/
|
||||||
public static function attach_generated_images( int $post_id, PRAutoBlogger_Article_Idea $idea, array $post_data, ?PRAutoBlogger_Cost_Tracker $cost_tracker = null ): void {
|
public static function attach_generated_images( int $post_id, PRAutoBlogger_Article_Idea $idea, array $post_data ): void {
|
||||||
// Fetch the original Reddit source data for Image B's source-driven prompt.
|
// Fetch the original Reddit source data for Image B's source-driven prompt.
|
||||||
// The idea's source_ids point to rows in the source_data table collected
|
// The idea's source_ids point to rows in the source_data table collected
|
||||||
// during the pipeline's collection step.
|
// during the pipeline's collection step.
|
||||||
@@ -248,7 +247,7 @@ class PRAutoBlogger_Post_Assembler {
|
|||||||
// internally, immediately after each image generates. This
|
// internally, immediately after each image generates. This
|
||||||
// ensures attachment persists even if the process times out
|
// ensures attachment persists even if the process times out
|
||||||
// before this method returns.
|
// before this method returns.
|
||||||
$result = ( new PRAutoBlogger_Image_Pipeline( null, $cost_tracker ) )->generate_and_attach_images( $post_id, $post_data, $source_data );
|
$result = ( new PRAutoBlogger_Image_Pipeline() )->generate_and_attach_images( $post_id, $post_data, $source_data );
|
||||||
|
|
||||||
if ( ! empty( $result['errors'] ) ) {
|
if ( ! empty( $result['errors'] ) ) {
|
||||||
foreach ( $result['errors'] as $error ) {
|
foreach ( $result['errors'] as $error ) {
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ class PRAutoBlogger_Publisher {
|
|||||||
* @param PRAutoBlogger_Article_Idea $idea The original article idea.
|
* @param PRAutoBlogger_Article_Idea $idea The original article idea.
|
||||||
* @param PRAutoBlogger_Editorial_Review $review The editor's review.
|
* @param PRAutoBlogger_Editorial_Review $review The editor's review.
|
||||||
* @param string|null $run_id Pipeline run ID for log linking.
|
* @param string|null $run_id Pipeline run ID for log linking.
|
||||||
* @param ?PRAutoBlogger_Cost_Tracker $cost_tracker Optional cost tracker for image generation cost logging.
|
|
||||||
* @return int The created post ID.
|
* @return int The created post ID.
|
||||||
* @throws \RuntimeException If post creation fails.
|
* @throws \RuntimeException If post creation fails.
|
||||||
*/
|
*/
|
||||||
@@ -36,10 +35,9 @@ class PRAutoBlogger_Publisher {
|
|||||||
string $content,
|
string $content,
|
||||||
PRAutoBlogger_Article_Idea $idea,
|
PRAutoBlogger_Article_Idea $idea,
|
||||||
PRAutoBlogger_Editorial_Review $review,
|
PRAutoBlogger_Editorial_Review $review,
|
||||||
?string $run_id = null,
|
?string $run_id = null
|
||||||
?PRAutoBlogger_Cost_Tracker $cost_tracker = null
|
|
||||||
): int {
|
): int {
|
||||||
return $this->create_post( $content, $idea, $review, 'publish', $run_id, $cost_tracker );
|
return $this->create_post( $content, $idea, $review, 'publish', $run_id );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,7 +47,6 @@ class PRAutoBlogger_Publisher {
|
|||||||
* @param PRAutoBlogger_Article_Idea $idea The original article idea.
|
* @param PRAutoBlogger_Article_Idea $idea The original article idea.
|
||||||
* @param PRAutoBlogger_Editorial_Review $review The editor's review with rejection notes.
|
* @param PRAutoBlogger_Editorial_Review $review The editor's review with rejection notes.
|
||||||
* @param string|null $run_id Pipeline run ID for log linking.
|
* @param string|null $run_id Pipeline run ID for log linking.
|
||||||
* @param ?PRAutoBlogger_Cost_Tracker $cost_tracker Optional cost tracker for image generation cost logging.
|
|
||||||
* @return int The created post ID.
|
* @return int The created post ID.
|
||||||
* @throws \RuntimeException If post creation fails.
|
* @throws \RuntimeException If post creation fails.
|
||||||
*/
|
*/
|
||||||
@@ -57,10 +54,9 @@ class PRAutoBlogger_Publisher {
|
|||||||
string $content,
|
string $content,
|
||||||
PRAutoBlogger_Article_Idea $idea,
|
PRAutoBlogger_Article_Idea $idea,
|
||||||
PRAutoBlogger_Editorial_Review $review,
|
PRAutoBlogger_Editorial_Review $review,
|
||||||
?string $run_id = null,
|
?string $run_id = null
|
||||||
?PRAutoBlogger_Cost_Tracker $cost_tracker = null
|
|
||||||
): int {
|
): int {
|
||||||
return $this->create_post( $content, $idea, $review, 'draft', $run_id, $cost_tracker );
|
return $this->create_post( $content, $idea, $review, 'draft', $run_id );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,8 +75,7 @@ class PRAutoBlogger_Publisher {
|
|||||||
PRAutoBlogger_Article_Idea $idea,
|
PRAutoBlogger_Article_Idea $idea,
|
||||||
PRAutoBlogger_Editorial_Review $review,
|
PRAutoBlogger_Editorial_Review $review,
|
||||||
string $post_status,
|
string $post_status,
|
||||||
?string $run_id = null,
|
?string $run_id = null
|
||||||
?PRAutoBlogger_Cost_Tracker $cost_tracker = null
|
|
||||||
): int {
|
): int {
|
||||||
// Clean LLM artifacts, then inject peptide hyperlinks deterministically
|
// Clean LLM artifacts, then inject peptide hyperlinks deterministically
|
||||||
// before the content enters WordPress. Peptide linker no-ops gracefully
|
// before the content enters WordPress. Peptide linker no-ops gracefully
|
||||||
@@ -111,7 +106,7 @@ class PRAutoBlogger_Publisher {
|
|||||||
PRAutoBlogger_Post_Assembler::link_generation_logs( $post_id, $run_id );
|
PRAutoBlogger_Post_Assembler::link_generation_logs( $post_id, $run_id );
|
||||||
|
|
||||||
if ( 'publish' === $post_status ) {
|
if ( 'publish' === $post_status ) {
|
||||||
PRAutoBlogger_Post_Assembler::attach_generated_images( $post_id, $idea, $post_data, $cost_tracker );
|
PRAutoBlogger_Post_Assembler::attach_generated_images( $post_id, $idea, $post_data );
|
||||||
}
|
}
|
||||||
|
|
||||||
PRAutoBlogger_Logger::instance()->info(
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
|
|||||||
@@ -6,87 +6,44 @@ declare(strict_types=1);
|
|||||||
*
|
*
|
||||||
* Pricing + model-id resolution helpers for the Runware image provider.
|
* Pricing + model-id resolution helpers for the Runware image provider.
|
||||||
*
|
*
|
||||||
* Each Runware model has a flat per-image cost sourced from
|
* Each Runware FLUX model has a flat per-image cost sourced from
|
||||||
* runware.ai/pricing (April 2026). Unlike OpenRouter models, Runware
|
* runware.ai/pricing (April 2026). Unlike OpenRouter models, Runware
|
||||||
* prices scale slightly with steps — the quoted rate here assumes the
|
* prices scale slightly with steps — the quoted rate here assumes the
|
||||||
* provider's default step count for each model.
|
* provider's default step count (4 for schnell, 28 for dev).
|
||||||
*
|
*
|
||||||
* Triggered by: PRAutoBlogger_Runware_Image_Provider, PRAutoBlogger_Runware_Image_Batch.
|
* Triggered by: PRAutoBlogger_Runware_Image_Provider, PRAutoBlogger_Runware_Image_Batch.
|
||||||
* Dependencies: None — pure calculation + option reads.
|
* Dependencies: None — pure calculation + option reads.
|
||||||
*
|
*
|
||||||
* @see class-runware-image-provider.php — Primary caller.
|
* @see class-runware-image-provider.php — Primary caller.
|
||||||
* @see class-runware-model-catalog.php — Live model sync; authoritative pricing source.
|
|
||||||
* @see class-open-router-image-pricing.php — Sibling pattern for OpenRouter.
|
* @see class-open-router-image-pricing.php — Sibling pattern for OpenRouter.
|
||||||
* @see admin/class-image-model-registry.php — Source of truth for model list; keep in sync.
|
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger_Runware_Image_Pricing {
|
class PRAutoBlogger_Runware_Image_Pricing {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flat per-image cost (USD) for each supported Runware model, April 2026.
|
* Flat per-image cost (USD) for each supported Runware model, April 2026.
|
||||||
* Update here when Runware reprices or new models launch. Must stay in
|
* Update here when Runware reprices or new models launch.
|
||||||
* sync with the model list in PRAutoBlogger_Image_Model_Registry — any
|
|
||||||
* model listed there that is absent here will silently fall back to schnell.
|
|
||||||
*
|
*
|
||||||
* Runware model IDs use `family:variant@version` form.
|
* Runware model IDs use `family:variant@version` form. The two FLUX
|
||||||
|
* variants we support are the CEO-sanctioned defaults.
|
||||||
*
|
*
|
||||||
* @var array<string, float>
|
* @var array<string, float>
|
||||||
*/
|
*/
|
||||||
private const COST_PER_IMAGE = array(
|
private const COST_PER_IMAGE = array(
|
||||||
// FLUX.1 schnell — 4-step distilled. Default (2026-04-21).
|
// FLUX.1 schnell — 4-step distilled. Our new default (2026-04-21).
|
||||||
'runware:100@1' => 0.0006,
|
'runware:100@1' => 0.0006,
|
||||||
// TwinFlow Z-Image-Turbo — fast text-to-image, same cost as schnell.
|
// FLUX.1 dev — 28-step full model. Higher fidelity, ~30x cost.
|
||||||
'runware:twinflow-z-image-turbo@0' => 0.0006,
|
'runware:101@1' => 0.02,
|
||||||
// FLUX.2 klein 4B — newer architecture at schnell price.
|
|
||||||
'runware:400@4' => 0.0006,
|
|
||||||
// FLUX.2 klein 9B — stronger than schnell, similar cost.
|
|
||||||
'runware:400@2' => 0.00078,
|
|
||||||
// Stable Diffusion 3 — sharp text, complex scenes.
|
|
||||||
'runware:5@1' => 0.0013,
|
|
||||||
// Z-Image Turbo — fast photorealistic generation.
|
|
||||||
'runware:z-image@turbo' => 0.0013,
|
|
||||||
// HiDream-I1 Fast — low latency, 17B quality.
|
|
||||||
'runware:97@3' => 0.0038,
|
|
||||||
// HiDream-I1 Dev — strong prompt alignment.
|
|
||||||
'runware:97@2' => 0.0045,
|
|
||||||
// Z-Image — high-quality foundation model.
|
|
||||||
'runware:z-image@0' => 0.0045,
|
|
||||||
// FLUX.2 dev — controllable open text-to-image.
|
|
||||||
'runware:400@1' => 0.0051,
|
|
||||||
// Qwen-Image — strong text rendering in generated images.
|
|
||||||
'runware:108@1' => 0.0058,
|
|
||||||
// HiDream-I1 Full — 17B high-fidelity, LoRA support.
|
|
||||||
'runware:97@1' => 0.009,
|
|
||||||
// FLUX.1 Krea dev — photorealistic open-weight generation.
|
|
||||||
'runware:107@1' => 0.0098,
|
|
||||||
// FLUX.1 dev — 28-step full model. Higher fidelity, ~30x schnell cost.
|
|
||||||
'runware:101@1' => 0.02,
|
|
||||||
// GLM-Image — hybrid autoregressive+diffusion, excellent text rendering.
|
|
||||||
'runware:glm-image@0' => 0.0225,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default number of inference steps per model. Runware bills per image
|
* Default number of inference steps per model. Runware bills per image
|
||||||
* at the model's standard step count; custom step counts may alter price.
|
* at the model's standard step count; custom step counts may alter price.
|
||||||
* Sources: runware.ai/models (April 2026).
|
|
||||||
*
|
*
|
||||||
* @var array<string, int>
|
* @var array<string, int>
|
||||||
*/
|
*/
|
||||||
private const DEFAULT_STEPS = array(
|
private const DEFAULT_STEPS = array(
|
||||||
'runware:100@1' => 4,
|
'runware:100@1' => 4,
|
||||||
'runware:twinflow-z-image-turbo@0' => 4,
|
'runware:101@1' => 28,
|
||||||
'runware:400@4' => 20,
|
|
||||||
'runware:400@2' => 20,
|
|
||||||
'runware:5@1' => 28,
|
|
||||||
'runware:z-image@turbo' => 4,
|
|
||||||
'runware:97@3' => 16,
|
|
||||||
'runware:97@2' => 28,
|
|
||||||
'runware:z-image@0' => 28,
|
|
||||||
'runware:400@1' => 28,
|
|
||||||
'runware:108@1' => 20,
|
|
||||||
'runware:97@1' => 50,
|
|
||||||
'runware:107@1' => 28,
|
|
||||||
'runware:101@1' => 28,
|
|
||||||
'runware:glm-image@0' => 30,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,9 +77,9 @@ class PRAutoBlogger_Runware_Image_Pricing {
|
|||||||
/**
|
/**
|
||||||
* Estimate USD cost for a single image at the given model.
|
* Estimate USD cost for a single image at the given model.
|
||||||
*
|
*
|
||||||
* Runware pricing is flat per image at the model's standard step count.
|
* Runware FLUX pricing is flat per image at the model's standard step
|
||||||
* Width/height are accepted for interface compatibility but do not affect
|
* count. Width/height are accepted for interface compatibility but do
|
||||||
* cost for the variants we support.
|
* not affect cost for the variants we support.
|
||||||
*
|
*
|
||||||
* @param int $width Width in pixels (unused for flat pricing).
|
* @param int $width Width in pixels (unused for flat pricing).
|
||||||
* @param int $height Height in pixels (unused for flat pricing).
|
* @param int $height Height in pixels (unused for flat pricing).
|
||||||
|
|||||||
@@ -1,298 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
|
||||||
*
|
|
||||||
* Runware model catalog sync — fetches live model list via Runware's /v1 endpoint,
|
|
||||||
* filters to text-to-image tasks, caches normalized results, and falls back to
|
|
||||||
* hardcoded list on API failure.
|
|
||||||
*
|
|
||||||
* What: Syncs Runware's model catalog periodically (daily cron + on-demand AJAX),
|
|
||||||
* caches the results in WP options, and merges pricing data from the pricing
|
|
||||||
* class when Runware's API doesn't expose it.
|
|
||||||
* Who triggers it: Daily cron (prautoblogger_sync_runware_models), AJAX "Sync now"
|
|
||||||
* button in PRAdmin, and lazy-load via PRAutoBlogger_Image_Model_Registry.
|
|
||||||
* Dependencies: PRAutoBlogger_Logger, PRAutoBlogger_Runware_Image_Pricing,
|
|
||||||
* PRAutoBlogger_Image_Model_Registry.
|
|
||||||
*
|
|
||||||
* @see class-runware-image-pricing.php — Authoritative pricing source & fallback models.
|
|
||||||
* @see admin/class-image-model-registry.php — Caller; merges Runware + OpenRouter lists.
|
|
||||||
* @see class-prautoblogger.php — Registers daily cron + AJAX hook.
|
|
||||||
* @see class-activator.php / class-deactivator.php — Schedule / unschedule on activation/deactivation.
|
|
||||||
*/
|
|
||||||
class PRAutoBlogger_Runware_Model_Catalog {
|
|
||||||
|
|
||||||
private const RUNWARE_API_URL = 'https://api.runware.ai/v1';
|
|
||||||
private const CACHE_TTL_SECONDS = 86400;
|
|
||||||
private const HTTP_TIMEOUT_SECONDS = 30;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the Runware model catalog, normalize to the Image_Model_Registry
|
|
||||||
* shape, and write to WP options cache. Returns true on success, false on
|
|
||||||
* API failure or parse error.
|
|
||||||
*
|
|
||||||
* Side effects: makes HTTP request to Runware API, writes WP options.
|
|
||||||
* Error handling: logs via PRAutoBlogger_Logger, never throws, always
|
|
||||||
* returns bool to allow fallback to cached list.
|
|
||||||
*
|
|
||||||
* @return bool True on successful fetch and cache update; false on API or parse error.
|
|
||||||
*/
|
|
||||||
public function sync(): bool {
|
|
||||||
try {
|
|
||||||
$api_key = $this->get_api_key();
|
|
||||||
if ( '' === $api_key ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->warning(
|
|
||||||
'Runware model catalog sync: API key not configured. Using cached/fallback models.',
|
|
||||||
'runware-catalog'
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$raw_models = $this->fetch_models_from_api( $api_key );
|
|
||||||
if ( empty( $raw_models ) ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->warning(
|
|
||||||
'Runware model catalog fetch returned empty list. Using cached/fallback models.',
|
|
||||||
'runware-catalog'
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalized = $this->normalize_models( $raw_models );
|
|
||||||
$with_pricing = $this->merge_pricing( $normalized );
|
|
||||||
|
|
||||||
if ( empty( $with_pricing ) ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->warning(
|
|
||||||
'Runware model catalog normalization resulted in empty list. Using cached/fallback models.',
|
|
||||||
'runware-catalog'
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
update_option( 'prautoblogger_runware_model_cache', $with_pricing, false );
|
|
||||||
update_option( 'prautoblogger_runware_model_cache_updated_at', time(), false );
|
|
||||||
|
|
||||||
PRAutoBlogger_Logger::instance()->info(
|
|
||||||
sprintf( 'Runware model catalog synced: %d models cached.', count( $with_pricing ) ),
|
|
||||||
'runware-catalog'
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
} catch ( \Throwable $e ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->error(
|
|
||||||
sprintf( 'Runware model catalog sync failed: %s (%s)', $e->getMessage(), get_class( $e ) ),
|
|
||||||
'runware-catalog'
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the normalized model list, with smart caching logic:
|
|
||||||
* - If cache is fresh (< 24h), return it.
|
|
||||||
* - If cache is stale or absent, trigger a sync.
|
|
||||||
* - If sync fails, return the last-known-good cache.
|
|
||||||
* - If no cache exists, return the hardcoded fallback (never empty).
|
|
||||||
*
|
|
||||||
* @return array<int, array<string, mixed>> Normalized model list.
|
|
||||||
*/
|
|
||||||
public function get_models(): array {
|
|
||||||
if ( ! $this->is_stale() ) {
|
|
||||||
$cached = get_option( 'prautoblogger_runware_model_cache', array() );
|
|
||||||
if ( is_array( $cached ) && ! empty( $cached ) ) {
|
|
||||||
return $cached;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( $this->sync() ) {
|
|
||||||
$cached = get_option( 'prautoblogger_runware_model_cache', array() );
|
|
||||||
if ( is_array( $cached ) && ! empty( $cached ) ) {
|
|
||||||
return $cached;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$cached = get_option( 'prautoblogger_runware_model_cache', array() );
|
|
||||||
if ( is_array( $cached ) && ! empty( $cached ) ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->info(
|
|
||||||
'Using stale Runware model cache (live sync failed).',
|
|
||||||
'runware-catalog'
|
|
||||||
);
|
|
||||||
return $cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
$fallback = PRAutoBlogger_Image_Model_Registry::get_runware_fallback_models();
|
|
||||||
PRAutoBlogger_Logger::instance()->warning(
|
|
||||||
sprintf(
|
|
||||||
'No Runware model cache available; using hardcoded fallback (%d models).',
|
|
||||||
count( $fallback )
|
|
||||||
),
|
|
||||||
'runware-catalog'
|
|
||||||
);
|
|
||||||
return $fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the Unix timestamp of the last successful sync, or null if never synced.
|
|
||||||
*
|
|
||||||
* @return int|null Unix timestamp, or null if never synced.
|
|
||||||
*/
|
|
||||||
public function get_last_synced_at(): ?int {
|
|
||||||
$ts = get_option( 'prautoblogger_runware_model_cache_updated_at', null );
|
|
||||||
if ( null === $ts ) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
$ts = (int) $ts;
|
|
||||||
return $ts > 0 ? $ts : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the cache is stale (older than 24h) or absent.
|
|
||||||
*
|
|
||||||
* @return bool True if cache should be refreshed; false if still fresh.
|
|
||||||
*/
|
|
||||||
public function is_stale(): bool {
|
|
||||||
$updated_at = $this->get_last_synced_at();
|
|
||||||
if ( null === $updated_at ) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return ( time() - $updated_at ) > self::CACHE_TTL_SECONDS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the raw model list from Runware's /v1 endpoint via a models task.
|
|
||||||
*
|
|
||||||
* @param string $api_key Decrypted Runware API key.
|
|
||||||
* @return array Raw model list (may be empty).
|
|
||||||
* @throws \RuntimeException On HTTP error or malformed response.
|
|
||||||
*/
|
|
||||||
private function fetch_models_from_api( string $api_key ): array {
|
|
||||||
$body = wp_json_encode(
|
|
||||||
array(
|
|
||||||
array(
|
|
||||||
'taskType' => 'models',
|
|
||||||
'apiKey' => $api_key,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$response = wp_remote_post(
|
|
||||||
self::RUNWARE_API_URL,
|
|
||||||
array(
|
|
||||||
'timeout' => self::HTTP_TIMEOUT_SECONDS,
|
|
||||||
'headers' => array( 'Content-Type' => 'application/json' ),
|
|
||||||
'body' => $body,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if ( is_wp_error( $response ) ) {
|
|
||||||
throw new \RuntimeException(
|
|
||||||
sprintf( 'Runware models endpoint unreachable: %s', $response->get_error_message() )
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$status = (int) wp_remote_retrieve_response_code( $response );
|
|
||||||
if ( 200 !== $status ) {
|
|
||||||
$raw = wp_remote_retrieve_body( $response );
|
|
||||||
throw new \RuntimeException(
|
|
||||||
sprintf( 'Runware models endpoint returned HTTP %d', $status )
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$raw = (string) wp_remote_retrieve_body( $response );
|
|
||||||
$decoded = json_decode( $raw, true );
|
|
||||||
|
|
||||||
if ( ! is_array( $decoded ) ) {
|
|
||||||
throw new \RuntimeException( 'Runware models response is not valid JSON.' );
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( isset( $decoded['errors'] ) && is_array( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) {
|
|
||||||
throw new \RuntimeException( 'Runware API returned an error.' );
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = $decoded['data'] ?? array();
|
|
||||||
return is_array( $data ) ? $data : array();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize raw Runware model objects to the PRAutoBlogger_Image_Model_Registry
|
|
||||||
* shape: id, name, provider, cost_per_image, capabilities, description.
|
|
||||||
*
|
|
||||||
* Filters to taskType='imageInference' (text-to-image only), excluding
|
|
||||||
* inpainting (imageInference + requiresImage=true), img2img, video, upscalers.
|
|
||||||
*
|
|
||||||
* @param array<int, array<string, mixed>> $raw_models Raw API response.
|
|
||||||
* @return array<int, array<string, mixed>> Normalized models.
|
|
||||||
*/
|
|
||||||
private function normalize_models( array $raw_models ): array {
|
|
||||||
$normalized = array();
|
|
||||||
|
|
||||||
foreach ( $raw_models as $raw ) {
|
|
||||||
if ( ! is_array( $raw ) ) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$task_type = $raw['taskType'] ?? '';
|
|
||||||
if ( 'imageInference' !== $task_type ) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( isset( $raw['requiresImage'] ) && true === $raw['requiresImage'] ) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$model_id = (string) ( $raw['id'] ?? '' );
|
|
||||||
if ( '' === $model_id ) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalized[] = array(
|
|
||||||
'id' => $model_id,
|
|
||||||
'name' => (string) ( $raw['name'] ?? $model_id ),
|
|
||||||
'provider' => 'runware',
|
|
||||||
'cost_per_image' => null,
|
|
||||||
'capabilities' => array( 'image_generation' ),
|
|
||||||
'description' => (string) ( $raw['description'] ?? '' ),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge pricing data from PRAutoBlogger_Runware_Image_Pricing into the
|
|
||||||
* normalized catalog. Models without pricing data get cost_per_image=null.
|
|
||||||
*
|
|
||||||
* @param array<int, array<string, mixed>> $normalized Normalized models.
|
|
||||||
* @return array<int, array<string, mixed>> Models with pricing merged.
|
|
||||||
*/
|
|
||||||
private function merge_pricing( array $normalized ): array {
|
|
||||||
$pricing_table = PRAutoBlogger_Runware_Image_Pricing::get_model_costs();
|
|
||||||
|
|
||||||
foreach ( $normalized as &$model ) {
|
|
||||||
$model_id = $model['id'] ?? '';
|
|
||||||
if ( isset( $pricing_table[ $model_id ] ) ) {
|
|
||||||
$model['cost_per_image'] = (float) $pricing_table[ $model_id ];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the decrypted Runware API key from settings.
|
|
||||||
*
|
|
||||||
* @return string Plaintext key, or empty string if not configured.
|
|
||||||
*/
|
|
||||||
private function get_api_key(): string {
|
|
||||||
$stored = (string) get_option( 'prautoblogger_runware_api_key', '' );
|
|
||||||
if ( '' === $stored ) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( PRAutoBlogger_Encryption::is_encrypted( $stored ) ) {
|
|
||||||
$decrypted = PRAutoBlogger_Encryption::decrypt( $stored );
|
|
||||||
return '' === $decrypted ? '' : $decrypted;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $stored;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opik REST API HTTP client.
|
|
||||||
*
|
|
||||||
* Minimal PHP HTTP client for posting traces and spans to Comet Opik.
|
|
||||||
* Handles authentication, retry logic, exponential backoff, and payload sizing.
|
|
||||||
*
|
|
||||||
* Feature-flag gated: if PRAUTOBLOGGER_OPIK_ENABLED is false, this class
|
|
||||||
* should not be instantiated or used.
|
|
||||||
*
|
|
||||||
* No official PHP SDK exists, so this is a thin wrapper around wp_remote_post().
|
|
||||||
*
|
|
||||||
* @see https://www.comet.com/docs/opik/reference/rest-api/overview
|
|
||||||
* @see includes/services/opik/class-opik-trace-context.php
|
|
||||||
* @see includes/services/opik/class-opik-dispatcher.php
|
|
||||||
*/
|
|
||||||
class PRAutoBlogger_Opik_Client {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string Base URL for Opik API (can be overridden via constant).
|
|
||||||
*/
|
|
||||||
private string $base_url;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string Opik API key (from PRAUTOBLOGGER_OPIK_API_KEY constant).
|
|
||||||
*/
|
|
||||||
private string $api_key;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string Opik workspace name (from PRAUTOBLOGGER_OPIK_WORKSPACE constant).
|
|
||||||
*/
|
|
||||||
private string $workspace;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var int HTTP timeout in seconds.
|
|
||||||
*/
|
|
||||||
private int $timeout;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constructor.
|
|
||||||
*
|
|
||||||
* @param string $api_key Opik API key. Use empty string if disabled.
|
|
||||||
* @param string $workspace Opik workspace (e.g. 'peptiderepo'). Use empty if disabled.
|
|
||||||
* @param string $base_url Base URL for Opik API. Defaults to https://www.comet.com/opik/api/
|
|
||||||
* @param int $timeout HTTP timeout in seconds. Defaults to 30.
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
string $api_key,
|
|
||||||
string $workspace,
|
|
||||||
string $base_url = 'https://www.comet.com/opik/api/',
|
|
||||||
int $timeout = 30
|
|
||||||
) {
|
|
||||||
$this->api_key = $api_key;
|
|
||||||
$this->workspace = $workspace;
|
|
||||||
$this->base_url = rtrim( $base_url, '/' ) . '/';
|
|
||||||
$this->timeout = $timeout;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a trace (top-level span container for one article generation).
|
|
||||||
*
|
|
||||||
* @param array{
|
|
||||||
* id: string,
|
|
||||||
* name: string,
|
|
||||||
* project_name: string,
|
|
||||||
* start_time: string,
|
|
||||||
* end_time: string,
|
|
||||||
* input: array,
|
|
||||||
* output: array,
|
|
||||||
* metadata: array
|
|
||||||
* } $trace Trace payload.
|
|
||||||
*
|
|
||||||
* @return array|null Response from Opik, or null on persistent failure.
|
|
||||||
*/
|
|
||||||
public function create_trace( array $trace ): ?array {
|
|
||||||
$endpoint = 'v1/private/traces';
|
|
||||||
return $this->post( $endpoint, $trace );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a single span (LLM call record).
|
|
||||||
*
|
|
||||||
* @param array{
|
|
||||||
* id: string,
|
|
||||||
* trace_id: string,
|
|
||||||
* parent_span_id?: string,
|
|
||||||
* name: string,
|
|
||||||
* type: string,
|
|
||||||
* start_time: string,
|
|
||||||
* end_time: string,
|
|
||||||
* input: array,
|
|
||||||
* output: array,
|
|
||||||
* model: string,
|
|
||||||
* provider: string,
|
|
||||||
* usage: array{prompt_tokens: int, completion_tokens: int, total_tokens: int}
|
|
||||||
* } $span Span payload.
|
|
||||||
*
|
|
||||||
* @return array|null Response from Opik, or null on persistent failure.
|
|
||||||
*/
|
|
||||||
public function create_span( array $span ): ?array {
|
|
||||||
$endpoint = 'v1/private/spans';
|
|
||||||
return $this->post( $endpoint, $span );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Batch create spans (preferred for high volume).
|
|
||||||
*
|
|
||||||
* @param array $spans Array of span payloads (max 100 per batch).
|
|
||||||
*
|
|
||||||
* @return array|null Response from Opik, or null on persistent failure.
|
|
||||||
*/
|
|
||||||
public function batch_create_spans( array $spans ): ?array {
|
|
||||||
// Enforce batch size limit.
|
|
||||||
if ( count( $spans ) > 100 ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->warning(
|
|
||||||
sprintf( 'Batch span count %d exceeds 100 limit; truncating', count( $spans ) ),
|
|
||||||
'opik'
|
|
||||||
);
|
|
||||||
$spans = array_slice( $spans, 0, 100 );
|
|
||||||
}
|
|
||||||
|
|
||||||
$endpoint = 'v1/private/spans/batch';
|
|
||||||
$payload = array( 'spans' => $spans );
|
|
||||||
return $this->post( $endpoint, $payload );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Batch create traces.
|
|
||||||
*
|
|
||||||
* @param array $traces Array of trace payloads (max 100 per batch).
|
|
||||||
*
|
|
||||||
* @return array|null Response from Opik, or null on persistent failure.
|
|
||||||
*/
|
|
||||||
public function batch_create_traces( array $traces ): ?array {
|
|
||||||
// Enforce batch size limit.
|
|
||||||
if ( count( $traces ) > 100 ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->warning(
|
|
||||||
sprintf( 'Batch trace count %d exceeds 100 limit; truncating', count( $traces ) ),
|
|
||||||
'opik'
|
|
||||||
);
|
|
||||||
$traces = array_slice( $traces, 0, 100 );
|
|
||||||
}
|
|
||||||
|
|
||||||
$endpoint = 'v1/private/traces/batch';
|
|
||||||
$payload = array( 'traces' => $traces );
|
|
||||||
return $this->post( $endpoint, $payload );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST to Opik with exponential backoff retry on transient errors.
|
|
||||||
*
|
|
||||||
* Retries on: HTTP 5xx, timeout, network error.
|
|
||||||
* Does not retry on: 4xx (permanent errors).
|
|
||||||
*
|
|
||||||
* @param string $endpoint Relative endpoint path (e.g. 'v1/private/traces').
|
|
||||||
* @param array $payload JSON-serializable payload.
|
|
||||||
* @param int $attempt Internal retry counter (start at 0).
|
|
||||||
*
|
|
||||||
* @return array|null Decoded JSON response, or null on final failure.
|
|
||||||
*/
|
|
||||||
private function post( string $endpoint, array $payload, int $attempt = 0 ): ?array {
|
|
||||||
$max_attempts = 3;
|
|
||||||
|
|
||||||
$url = $this->base_url . $endpoint;
|
|
||||||
$headers = $this->build_headers();
|
|
||||||
$body = wp_json_encode( $payload );
|
|
||||||
|
|
||||||
if ( false === $body ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->error(
|
|
||||||
'Failed to JSON-encode Opik payload',
|
|
||||||
'opik'
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$args = array(
|
|
||||||
'method' => 'POST',
|
|
||||||
'headers' => $headers,
|
|
||||||
'body' => $body,
|
|
||||||
'timeout' => $this->timeout,
|
|
||||||
'data_format' => 'body',
|
|
||||||
);
|
|
||||||
|
|
||||||
$response = wp_remote_post( $url, $args );
|
|
||||||
|
|
||||||
if ( is_wp_error( $response ) ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->warning(
|
|
||||||
sprintf(
|
|
||||||
'Opik HTTP error (attempt %d/%d): %s',
|
|
||||||
$attempt + 1,
|
|
||||||
$max_attempts,
|
|
||||||
$response->get_error_message()
|
|
||||||
),
|
|
||||||
'opik'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Retry on network/timeout errors.
|
|
||||||
if ( $attempt < $max_attempts - 1 ) {
|
|
||||||
$delay = (int) ( 2 ** ( $attempt + 1 ) ); // 2s, 4s, 8s
|
|
||||||
sleep( $delay );
|
|
||||||
return $this->post( $endpoint, $payload, $attempt + 1 );
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$code = (int) wp_remote_retrieve_response_code( $response );
|
|
||||||
|
|
||||||
// Success.
|
|
||||||
if ( 200 <= $code && $code < 300 ) {
|
|
||||||
$body_text = wp_remote_retrieve_body( $response );
|
|
||||||
$data = json_decode( $body_text, true );
|
|
||||||
return is_array( $data ) ? $data : array();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Permanent client error.
|
|
||||||
if ( 400 <= $code && $code < 500 ) {
|
|
||||||
$body_text = wp_remote_retrieve_body( $response );
|
|
||||||
PRAutoBlogger_Logger::instance()->error(
|
|
||||||
sprintf(
|
|
||||||
'Opik API error (HTTP %d): %s',
|
|
||||||
$code,
|
|
||||||
mb_substr( $body_text, 0, 200 )
|
|
||||||
),
|
|
||||||
'opik'
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transient server error; retry with exponential backoff.
|
|
||||||
if ( 500 <= $code && $code < 600 ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->warning(
|
|
||||||
sprintf(
|
|
||||||
'Opik server error (HTTP %d, attempt %d/%d)',
|
|
||||||
$code,
|
|
||||||
$attempt + 1,
|
|
||||||
$max_attempts
|
|
||||||
),
|
|
||||||
'opik'
|
|
||||||
);
|
|
||||||
|
|
||||||
if ( $attempt < $max_attempts - 1 ) {
|
|
||||||
$delay = (int) ( 2 ** ( $attempt + 1 ) );
|
|
||||||
sleep( $delay );
|
|
||||||
return $this->post( $endpoint, $payload, $attempt + 1 );
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unexpected status.
|
|
||||||
PRAutoBlogger_Logger::instance()->warning(
|
|
||||||
sprintf( 'Opik unexpected HTTP %d', $code ),
|
|
||||||
'opik'
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build HTTP headers for Opik API requests.
|
|
||||||
*
|
|
||||||
* Auth: Authorization: <api_key> (raw, not Bearer)
|
|
||||||
* Workspace: Comet-Workspace: peptiderepo
|
|
||||||
*
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
private function build_headers(): array {
|
|
||||||
return array(
|
|
||||||
'Content-Type' => 'application/json',
|
|
||||||
'Authorization' => $this->api_key,
|
|
||||||
'Comet-Workspace' => $this->workspace,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opik dispatcher — cron handler for async span/trace dispatch.
|
|
||||||
*
|
|
||||||
* Triggered by `prautoblogger_opik_dispatch` action via wp_schedule_single_event().
|
|
||||||
* Drains the queue, batches spans, and POSTs to Opik with exponential backoff.
|
|
||||||
*
|
|
||||||
* Handles retries per-batch (not per-item). Failed batches are re-enqueued
|
|
||||||
* for the next cron tick.
|
|
||||||
*
|
|
||||||
* @see includes/services/opik/class-opik-client.php
|
|
||||||
* @see includes/services/opik/class-opik-span-queue.php
|
|
||||||
*/
|
|
||||||
class PRAutoBlogger_Opik_Dispatcher {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var PRAutoBlogger_Opik_Client
|
|
||||||
*/
|
|
||||||
private PRAutoBlogger_Opik_Client $client;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var PRAutoBlogger_Opik_Span_Queue
|
|
||||||
*/
|
|
||||||
private PRAutoBlogger_Opik_Span_Queue $queue;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constructor.
|
|
||||||
*
|
|
||||||
* @param PRAutoBlogger_Opik_Client $client Client instance.
|
|
||||||
* @param PRAutoBlogger_Opik_Span_Queue $queue Queue instance.
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
PRAutoBlogger_Opik_Client $client,
|
|
||||||
PRAutoBlogger_Opik_Span_Queue $queue
|
|
||||||
) {
|
|
||||||
$this->client = $client;
|
|
||||||
$this->queue = $queue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dispatch queued spans/traces to Opik.
|
|
||||||
*
|
|
||||||
* Called via wp_schedule_single_event(). Drains the queue in batches
|
|
||||||
* and POSTs to Opik. Failed batches are re-enqueued.
|
|
||||||
*
|
|
||||||
* This is the primary cron handler registered to 'prautoblogger_opik_dispatch'.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function dispatch(): void {
|
|
||||||
if ( ! $this->is_enabled() ) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$batch = $this->queue->dequeue( 100 );
|
|
||||||
if ( empty( $batch ) ) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$spans = array();
|
|
||||||
$traces = array();
|
|
||||||
|
|
||||||
foreach ( $batch as $item ) {
|
|
||||||
if ( 'trace' === $item['type'] ) {
|
|
||||||
$traces[] = $item['payload'];
|
|
||||||
} else {
|
|
||||||
$spans[] = $item['payload'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$failed_items = array();
|
|
||||||
|
|
||||||
// POST traces first.
|
|
||||||
if ( ! empty( $traces ) ) {
|
|
||||||
$success = $this->post_traces( $traces );
|
|
||||||
if ( ! $success ) {
|
|
||||||
$failed_items = array_merge(
|
|
||||||
$failed_items,
|
|
||||||
array_filter(
|
|
||||||
$batch,
|
|
||||||
function ( $item ) {
|
|
||||||
return 'trace' === $item['type'];
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then POST spans.
|
|
||||||
if ( ! empty( $spans ) ) {
|
|
||||||
$success = $this->post_spans( $spans );
|
|
||||||
if ( ! $success ) {
|
|
||||||
$failed_items = array_merge(
|
|
||||||
$failed_items,
|
|
||||||
array_filter(
|
|
||||||
$batch,
|
|
||||||
function ( $item ) {
|
|
||||||
return 'span' === $item['type'];
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-enqueue failed items with backoff.
|
|
||||||
if ( ! empty( $failed_items ) ) {
|
|
||||||
$this->queue->reenqueue( $failed_items );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log summary.
|
|
||||||
PRAutoBlogger_Logger::instance()->info(
|
|
||||||
sprintf(
|
|
||||||
'Opik dispatch: %d spans, %d traces, %d failed; queue depth now %d',
|
|
||||||
count( $spans ),
|
|
||||||
count( $traces ),
|
|
||||||
count( $failed_items ),
|
|
||||||
$this->queue->get_queue_depth()
|
|
||||||
),
|
|
||||||
'opik'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST traces to Opik.
|
|
||||||
*
|
|
||||||
* @param array $traces Array of trace payloads.
|
|
||||||
*
|
|
||||||
* @return bool True on success, false on failure.
|
|
||||||
*/
|
|
||||||
private function post_traces( array $traces ): bool {
|
|
||||||
if ( count( $traces ) === 1 ) {
|
|
||||||
// Single trace: use create_trace.
|
|
||||||
$response = $this->client->create_trace( $traces[0] );
|
|
||||||
return null !== $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multiple traces: use batch.
|
|
||||||
$response = $this->client->batch_create_traces( $traces );
|
|
||||||
return null !== $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST spans to Opik.
|
|
||||||
*
|
|
||||||
* @param array $spans Array of span payloads.
|
|
||||||
*
|
|
||||||
* @return bool True on success, false on failure.
|
|
||||||
*/
|
|
||||||
private function post_spans( array $spans ): bool {
|
|
||||||
if ( count( $spans ) === 1 ) {
|
|
||||||
// Single span: use create_span.
|
|
||||||
$response = $this->client->create_span( $spans[0] );
|
|
||||||
return null !== $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multiple spans: use batch.
|
|
||||||
$response = $this->client->batch_create_spans( $spans );
|
|
||||||
return null !== $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if Opik is enabled via feature flag and has credentials.
|
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
private function is_enabled(): bool {
|
|
||||||
if ( ! get_option( 'prautoblogger_opik_enabled', false ) ) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Credentials must be defined in wp-config.php.
|
|
||||||
if ( ! defined( 'PRAUTOBLOGGER_OPIK_API_KEY' ) ||
|
|
||||||
! defined( 'PRAUTOBLOGGER_OPIK_WORKSPACE' ) ) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$api_key = PRAUTOBLOGGER_OPIK_API_KEY;
|
|
||||||
$workspace = PRAUTOBLOGGER_OPIK_WORKSPACE;
|
|
||||||
|
|
||||||
return ! empty( $api_key ) && ! empty( $workspace );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Orchestrates a full eval pass — loads dataset, runs generation pipeline
|
|
||||||
* in dry-run mode, scores outputs, pushes results to an Opik experiment.
|
|
||||||
*
|
|
||||||
* Triggered by: PRAutoBlogger_WP_CLI_Commands via `wp prautoblogger opik:eval`.
|
|
||||||
* Dependencies: PRAutoBlogger_Content_Generator, PRAutoBlogger_Opik_Eval_Scorer,
|
|
||||||
* PRAutoBlogger_Opik_Client, PRAutoBlogger_Cost_Tracker.
|
|
||||||
*/
|
|
||||||
class PRAutoBlogger_Opik_Eval_Runner {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var PRAutoBlogger_Content_Generator Content generator.
|
|
||||||
*/
|
|
||||||
private PRAutoBlogger_Content_Generator $generator;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var PRAutoBlogger_Opik_Eval_Scorer LLM-as-judge scorer.
|
|
||||||
*/
|
|
||||||
private PRAutoBlogger_Opik_Eval_Scorer $scorer;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var PRAutoBlogger_Opik_Client Opik API client.
|
|
||||||
*/
|
|
||||||
private PRAutoBlogger_Opik_Client $opik_client;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var PRAutoBlogger_Cost_Tracker Cost tracker.
|
|
||||||
*/
|
|
||||||
private PRAutoBlogger_Cost_Tracker $cost_tracker;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constructor.
|
|
||||||
*
|
|
||||||
* @param PRAutoBlogger_Content_Generator|null $generator Optional generator override.
|
|
||||||
* @param PRAutoBlogger_Opik_Eval_Scorer|null $scorer Optional scorer override.
|
|
||||||
* @param PRAutoBlogger_Opik_Client|null $opik_client Optional Opik client.
|
|
||||||
* @param PRAutoBlogger_Cost_Tracker|null $cost_tracker Optional cost tracker.
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
?PRAutoBlogger_Content_Generator $generator = null,
|
|
||||||
?PRAutoBlogger_Opik_Eval_Scorer $scorer = null,
|
|
||||||
?PRAutoBlogger_Opik_Client $opik_client = null,
|
|
||||||
?PRAutoBlogger_Cost_Tracker $cost_tracker = null
|
|
||||||
) {
|
|
||||||
$this->generator = $generator ?? new PRAutoBlogger_Content_Generator( new PRAutoBlogger_OpenRouter_Provider(), new PRAutoBlogger_Cost_Tracker() );
|
|
||||||
$this->scorer = $scorer ?? new PRAutoBlogger_Opik_Eval_Scorer();
|
|
||||||
$this->opik_client = $opik_client ?? new PRAutoBlogger_Opik_Client( PRAUTOBLOGGER_OPIK_API_KEY, PRAUTOBLOGGER_OPIK_WORKSPACE );
|
|
||||||
$this->cost_tracker = $cost_tracker ?? new PRAutoBlogger_Cost_Tracker();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run an eval pass on the frozen dataset.
|
|
||||||
*
|
|
||||||
* @param int $limit Max number of items to run (0 = all). Defaults to 0.
|
|
||||||
* @param bool $dry_run Skip Opik API push if true. Defaults to false.
|
|
||||||
*
|
|
||||||
* @return array{
|
|
||||||
* items_run: int,
|
|
||||||
* avg_scores: array{scientific_signal: float, readability: float, style_adherence: float},
|
|
||||||
* total_cost_usd: float
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function run( int $limit = 0, bool $dry_run = false ): array {
|
|
||||||
$dataset = $this->load_dataset();
|
|
||||||
if ( empty( $dataset ) ) {
|
|
||||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_echo -- CLI output
|
|
||||||
echo "Error: Could not load eval dataset.\n";
|
|
||||||
return array(
|
|
||||||
'items_run' => 0,
|
|
||||||
'avg_scores' => array(),
|
|
||||||
'total_cost_usd' => 0.0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limit items if requested.
|
|
||||||
if ( $limit > 0 ) {
|
|
||||||
$dataset = array_slice( $dataset, 0, $limit );
|
|
||||||
}
|
|
||||||
|
|
||||||
$experiment_id = null;
|
|
||||||
if ( ! $dry_run && $this->is_opik_enabled() ) {
|
|
||||||
$experiment_id = $this->create_experiment();
|
|
||||||
}
|
|
||||||
|
|
||||||
$items_run = 0;
|
|
||||||
$total_scores = array(
|
|
||||||
'scientific_signal' => 0,
|
|
||||||
'readability' => 0,
|
|
||||||
'style_adherence' => 0,
|
|
||||||
);
|
|
||||||
$total_cost = 0.0;
|
|
||||||
|
|
||||||
foreach ( $dataset as $idx => $item ) {
|
|
||||||
$item_num = $idx + 1;
|
|
||||||
$total = count( $dataset );
|
|
||||||
|
|
||||||
// Generate content (no publishing, no image generation).
|
|
||||||
$content = $this->generate_eval_content( $item['topic'] );
|
|
||||||
if ( empty( $content ) ) {
|
|
||||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_echo -- CLI output
|
|
||||||
printf( "[%d/%d] %s | SKIPPED (generation failed)\n", $item_num, $total, $item['id'] );
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Score content.
|
|
||||||
$scores = $this->scorer->score( $item['topic'], $content );
|
|
||||||
|
|
||||||
$total_scores['scientific_signal'] += $scores['scientific_signal'];
|
|
||||||
$total_scores['readability'] += $scores['readability'];
|
|
||||||
$total_scores['style_adherence'] += $scores['style_adherence'];
|
|
||||||
$total_cost += $scores['cost_usd'];
|
|
||||||
|
|
||||||
// Push to Opik if enabled and not dry-run.
|
|
||||||
if ( ! $dry_run && null !== $experiment_id ) {
|
|
||||||
$this->push_to_opik( $experiment_id, $item, $scores, $content );
|
|
||||||
}
|
|
||||||
|
|
||||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_echo -- CLI output
|
|
||||||
printf(
|
|
||||||
"[%d/%d] %s | scientific=%d readability=%d style=%d\n",
|
|
||||||
$item_num,
|
|
||||||
$total,
|
|
||||||
$item['id'],
|
|
||||||
$scores['scientific_signal'],
|
|
||||||
$scores['readability'],
|
|
||||||
$scores['style_adherence']
|
|
||||||
);
|
|
||||||
|
|
||||||
$items_run++;
|
|
||||||
|
|
||||||
// Rate-limit: 500ms between items.
|
|
||||||
usleep( 500000 );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute averages.
|
|
||||||
$avg_scores = array();
|
|
||||||
if ( $items_run > 0 ) {
|
|
||||||
$avg_scores = array(
|
|
||||||
'scientific_signal' => round( $total_scores['scientific_signal'] / $items_run, 1 ),
|
|
||||||
'readability' => round( $total_scores['readability'] / $items_run, 1 ),
|
|
||||||
'style_adherence' => round( $total_scores['style_adherence'] / $items_run, 1 ),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->print_summary( $items_run, $avg_scores, $total_cost, $experiment_id );
|
|
||||||
|
|
||||||
return array(
|
|
||||||
'items_run' => $items_run,
|
|
||||||
'avg_scores' => $avg_scores,
|
|
||||||
'total_cost_usd' => $total_cost,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load the frozen eval dataset from eval/dataset.json.
|
|
||||||
*
|
|
||||||
* @return array Array of dataset items.
|
|
||||||
*/
|
|
||||||
private function load_dataset(): array {
|
|
||||||
$path = PRAUTOBLOGGER_PLUGIN_DIR . 'eval/dataset.json';
|
|
||||||
if ( ! file_exists( $path ) ) {
|
|
||||||
return array();
|
|
||||||
}
|
|
||||||
|
|
||||||
$json = file_get_contents( $path );
|
|
||||||
$data = json_decode( $json, true );
|
|
||||||
return is_array( $data ) ? $data : array();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate eval content (no publishing, no images).
|
|
||||||
*
|
|
||||||
* @param string $topic Topic string.
|
|
||||||
*
|
|
||||||
* @return string Generated content or empty string on failure.
|
|
||||||
*/
|
|
||||||
private function generate_eval_content( string $topic ): string {
|
|
||||||
// Create a minimal article idea for generation.
|
|
||||||
$idea = new PRAutoBlogger_Article_Idea();
|
|
||||||
$idea->set_topic( $topic );
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Generate content with eval_mode suppression.
|
|
||||||
$content = $this->generator->generate_eval( $idea );
|
|
||||||
return $content;
|
|
||||||
} catch ( \Throwable $e ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->warning(
|
|
||||||
sprintf( 'Eval generation failed for %s: %s', $topic, $e->getMessage() ),
|
|
||||||
'opik_eval'
|
|
||||||
);
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an Opik experiment for this eval run.
|
|
||||||
*
|
|
||||||
* @return string|null Experiment ID, or null on failure.
|
|
||||||
*/
|
|
||||||
private function create_experiment(): ?string {
|
|
||||||
$version = PRAUTOBLOGGER_VERSION;
|
|
||||||
$today = gmdate( 'Y-m-d' );
|
|
||||||
$name = "prautoblogger-eval-v{$version}-{$today}";
|
|
||||||
|
|
||||||
// Opik auto-creates experiments on first item POST, so we just use the name.
|
|
||||||
// The actual experiment object is created when the first item is pushed.
|
|
||||||
return $name;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Push an eval result to Opik as an experiment item.
|
|
||||||
*
|
|
||||||
* @param string $experiment_id Experiment name/ID.
|
|
||||||
* @param array $item Dataset item.
|
|
||||||
* @param array $scores Score result from scorer.
|
|
||||||
* @param string $content Generated content.
|
|
||||||
*/
|
|
||||||
private function push_to_opik( string $experiment_id, array $item, array $scores, string $content ): void {
|
|
||||||
$payload = array(
|
|
||||||
'experiment_id' => $experiment_id,
|
|
||||||
'dataset_item_id' => $item['id'] ?? 'unknown',
|
|
||||||
'output' => substr( $content, 0, 1000 ), // Truncate for Opik.
|
|
||||||
'metadata' => array(
|
|
||||||
'scientific_signal' => $scores['scientific_signal'],
|
|
||||||
'readability' => $scores['readability'],
|
|
||||||
'style_adherence' => $scores['style_adherence'],
|
|
||||||
'judge_model' => $scores['judge_model'],
|
|
||||||
'judge_cost' => $scores['cost_usd'],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Opik HTTP POST would go here, but for now we just queue it.
|
|
||||||
// The dispatcher will batch and send later if needed.
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Print the eval summary to stdout.
|
|
||||||
*
|
|
||||||
* @param int $items_run Number of items run.
|
|
||||||
* @param array $avg_scores Average scores.
|
|
||||||
* @param float $total_cost Total cost in USD.
|
|
||||||
* @param string|null $experiment_id Experiment ID.
|
|
||||||
*/
|
|
||||||
private function print_summary( int $items_run, array $avg_scores, float $total_cost, ?string $experiment_id ): void {
|
|
||||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_echo -- CLI output
|
|
||||||
printf( "\nEval complete — %d/%d items\n", $items_run, count( $this->load_dataset() ) );
|
|
||||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_echo -- CLI output
|
|
||||||
printf( "Avg scientific_signal : %.1f\n", $avg_scores['scientific_signal'] ?? 0 );
|
|
||||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_echo -- CLI output
|
|
||||||
printf( "Avg readability : %.1f\n", $avg_scores['readability'] ?? 0 );
|
|
||||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_echo -- CLI output
|
|
||||||
printf( "Avg style_adherence : %.1f\n", $avg_scores['style_adherence'] ?? 0 );
|
|
||||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_echo -- CLI output
|
|
||||||
printf( "Total cost : $%.3f\n", $total_cost );
|
|
||||||
|
|
||||||
if ( null !== $experiment_id ) {
|
|
||||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_echo -- CLI output
|
|
||||||
printf( "Experiment URL : https://www.comet.com/opik/peptiderepo/%s\n", urlencode( $experiment_id ) );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if Opik is enabled.
|
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
private function is_opik_enabled(): bool {
|
|
||||||
return ! empty( PRAUTOBLOGGER_OPIK_API_KEY ) && ! empty( PRAUTOBLOGGER_OPIK_WORKSPACE );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* LLM-as-judge scorer for eval content.
|
|
||||||
*
|
|
||||||
* Sends generated article content to a cheap judge model and returns
|
|
||||||
* structured quality scores on three axes: scientific_signal, readability,
|
|
||||||
* and style_adherence.
|
|
||||||
*
|
|
||||||
* Judge model: google/gemini-flash-1.5 via OpenRouter (configurable).
|
|
||||||
*
|
|
||||||
* Triggered by: PRAutoBlogger_Opik_Eval_Runner.
|
|
||||||
* Dependencies: PRAutoBlogger_OpenRouter_Provider, PRAutoBlogger_Cost_Tracker.
|
|
||||||
*/
|
|
||||||
class PRAutoBlogger_Opik_Eval_Scorer {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default judge model. Cheap and fast, good at JSON output.
|
|
||||||
* Configurable via PRAUTOBLOGGER_OPIK_JUDGE_MODEL constant.
|
|
||||||
*/
|
|
||||||
private const DEFAULT_JUDGE_MODEL = 'google/gemini-flash-1.5';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var PRAutoBlogger_OpenRouter_Provider LLM provider for judge.
|
|
||||||
*/
|
|
||||||
private PRAutoBlogger_OpenRouter_Provider $provider;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var PRAutoBlogger_Cost_Tracker Cost tracker for eval judge calls.
|
|
||||||
*/
|
|
||||||
private PRAutoBlogger_Cost_Tracker $cost_tracker;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constructor.
|
|
||||||
*
|
|
||||||
* @param PRAutoBlogger_OpenRouter_Provider|null $provider Optional provider override.
|
|
||||||
* @param PRAutoBlogger_Cost_Tracker|null $cost_tracker Optional cost tracker.
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
?PRAutoBlogger_OpenRouter_Provider $provider = null,
|
|
||||||
?PRAutoBlogger_Cost_Tracker $cost_tracker = null
|
|
||||||
) {
|
|
||||||
$this->provider = $provider ?? new PRAutoBlogger_OpenRouter_Provider();
|
|
||||||
$this->cost_tracker = $cost_tracker ?? new PRAutoBlogger_Cost_Tracker();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Score generated content on three axes.
|
|
||||||
*
|
|
||||||
* @param string $title Article title.
|
|
||||||
* @param string $content Generated article HTML/text content.
|
|
||||||
*
|
|
||||||
* @return array{
|
|
||||||
* scientific_signal: int,
|
|
||||||
* readability: int,
|
|
||||||
* style_adherence: int,
|
|
||||||
* rationale: string,
|
|
||||||
* judge_model: string,
|
|
||||||
* tokens: int,
|
|
||||||
* cost_usd: float
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function score( string $title, string $content ): array {
|
|
||||||
$system_prompt = $this->get_system_prompt();
|
|
||||||
$user_message = $this->build_user_message( $title, $content );
|
|
||||||
$model = $this->get_judge_model();
|
|
||||||
|
|
||||||
try {
|
|
||||||
$response = $this->provider->send_chat_completion(
|
|
||||||
array(
|
|
||||||
array(
|
|
||||||
'role' => 'system',
|
|
||||||
'content' => $system_prompt,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'role' => 'user',
|
|
||||||
'content' => $user_message,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
$model,
|
|
||||||
array(
|
|
||||||
'temperature' => 0.1,
|
|
||||||
'max_tokens' => 200,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$raw_json = trim( $response['content'] ?? '' );
|
|
||||||
|
|
||||||
// Log judge call cost.
|
|
||||||
$this->cost_tracker->log_api_call(
|
|
||||||
null,
|
|
||||||
'opik_eval_judge',
|
|
||||||
'openrouter',
|
|
||||||
$model,
|
|
||||||
$response['prompt_tokens'] ?? 0,
|
|
||||||
$response['completion_tokens'] ?? 0
|
|
||||||
);
|
|
||||||
|
|
||||||
$total_tokens = ( $response['prompt_tokens'] ?? 0 ) + ( $response['completion_tokens'] ?? 0 );
|
|
||||||
$cost = $this->provider->estimate_cost( $model, $response['prompt_tokens'] ?? 0, $response['completion_tokens'] ?? 0 );
|
|
||||||
|
|
||||||
// Parse JSON response.
|
|
||||||
$data = json_decode( $raw_json, true );
|
|
||||||
if ( ! is_array( $data ) ) {
|
|
||||||
$data = array();
|
|
||||||
}
|
|
||||||
|
|
||||||
return array(
|
|
||||||
'scientific_signal' => $this->clamp_score( $data['scientific_signal'] ?? 5 ),
|
|
||||||
'readability' => $this->clamp_score( $data['readability'] ?? 5 ),
|
|
||||||
'style_adherence' => $this->clamp_score( $data['style_adherence'] ?? 5 ),
|
|
||||||
'rationale' => $data['rationale'] ?? 'Judge call failed to parse.',
|
|
||||||
'judge_model' => $model,
|
|
||||||
'tokens' => $total_tokens,
|
|
||||||
'cost_usd' => $cost,
|
|
||||||
);
|
|
||||||
} catch ( \Throwable $e ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->warning(
|
|
||||||
sprintf( 'Eval scorer failed: %s', $e->getMessage() ),
|
|
||||||
'opik_eval'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Return neutral scores on failure.
|
|
||||||
return array(
|
|
||||||
'scientific_signal' => 5,
|
|
||||||
'readability' => 5,
|
|
||||||
'style_adherence' => 5,
|
|
||||||
'rationale' => 'Judge call failed (see logs).',
|
|
||||||
'judge_model' => $model,
|
|
||||||
'tokens' => 0,
|
|
||||||
'cost_usd' => 0.0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the judge system prompt.
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
private function get_system_prompt(): string {
|
|
||||||
return <<<'PROMPT'
|
|
||||||
You are a quality evaluator for science-adjacent health content. Score the following article on three axes. Respond ONLY with a JSON object — no explanation, no markdown.
|
|
||||||
|
|
||||||
Format:
|
|
||||||
{"scientific_signal": <0-10>, "readability": <0-10>, "style_adherence": <0-10>, "rationale": "<one sentence>"}
|
|
||||||
PROMPT;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the user message for the judge.
|
|
||||||
*
|
|
||||||
* @param string $title Article title.
|
|
||||||
* @param string $content Article content.
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
private function build_user_message( string $title, string $content ): string {
|
|
||||||
// Truncate content to keep tokens low (~500 limit).
|
|
||||||
$max_len = 2000;
|
|
||||||
if ( strlen( $content ) > $max_len ) {
|
|
||||||
$content = substr( $content, 0, $max_len ) . '...';
|
|
||||||
}
|
|
||||||
|
|
||||||
return sprintf(
|
|
||||||
"Title: %s\n\nContent:\n%s\n\nEvaluate this on scientific accuracy, readability, and adherence to single-panel comic style.",
|
|
||||||
esc_attr( $title ),
|
|
||||||
$content
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clamp a score to 0-10 range.
|
|
||||||
*
|
|
||||||
* @param mixed $score
|
|
||||||
*
|
|
||||||
* @return int
|
|
||||||
*/
|
|
||||||
private function clamp_score( $score ): int {
|
|
||||||
$val = (int) $score;
|
|
||||||
return max( 0, min( 10, $val ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the judge model, configurable via constant.
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
private function get_judge_model(): string {
|
|
||||||
if ( defined( 'PRAUTOBLOGGER_OPIK_JUDGE_MODEL' ) ) {
|
|
||||||
return PRAUTOBLOGGER_OPIK_JUDGE_MODEL;
|
|
||||||
}
|
|
||||||
return self::DEFAULT_JUDGE_MODEL;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Async queue for Opik span/trace payloads.
|
|
||||||
*
|
|
||||||
* Backs the queue with WP options (a single serialized array stored
|
|
||||||
* in wp_options). Payloads are enqueued at the end of article
|
|
||||||
* generation, dequeued and batch-posted via cron.
|
|
||||||
*
|
|
||||||
* Max queue depth is 1000 items; older items are expired TTL-based.
|
|
||||||
*
|
|
||||||
* @see includes/services/opik/class-opik-dispatcher.php — Drains the queue.
|
|
||||||
*/
|
|
||||||
class PRAutoBlogger_Opik_Span_Queue {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string WP option key storing the queue.
|
|
||||||
*/
|
|
||||||
const QUEUE_OPTION_KEY = 'prautoblogger_opik_queue';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var int Max queue depth (items).
|
|
||||||
*/
|
|
||||||
const MAX_QUEUE_DEPTH = 1000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var int Item TTL in seconds (12 hours).
|
|
||||||
*/
|
|
||||||
const ITEM_TTL_SECONDS = 43200;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enqueue a span or trace for async dispatch.
|
|
||||||
*
|
|
||||||
* @param array $payload Span or trace payload.
|
|
||||||
* @param string $type Either 'span' or 'trace'.
|
|
||||||
*
|
|
||||||
* @return bool True if enqueued, false if queue full.
|
|
||||||
*/
|
|
||||||
public function enqueue( array $payload, string $type = 'span' ): bool {
|
|
||||||
if ( ! in_array( $type, array( 'span', 'trace' ), true ) ) {
|
|
||||||
$type = 'span';
|
|
||||||
}
|
|
||||||
|
|
||||||
$queue = $this->get_queue();
|
|
||||||
|
|
||||||
if ( count( $queue ) >= self::MAX_QUEUE_DEPTH ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->warning(
|
|
||||||
sprintf( 'Opik queue full (%d items); dropping span', count( $queue ) ),
|
|
||||||
'opik'
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$item = array(
|
|
||||||
'type' => $type,
|
|
||||||
'payload' => $payload,
|
|
||||||
'enqueued' => time(),
|
|
||||||
'attempt' => 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
$queue[] = $item;
|
|
||||||
update_option( self::QUEUE_OPTION_KEY, $queue );
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dequeue the next N items from the queue for processing.
|
|
||||||
*
|
|
||||||
* @param int $limit Max items to dequeue.
|
|
||||||
*
|
|
||||||
* @return array Array of items with 'type', 'payload', 'enqueued', 'attempt'.
|
|
||||||
*/
|
|
||||||
public function dequeue( int $limit = 100 ): array {
|
|
||||||
$queue = $this->get_queue();
|
|
||||||
|
|
||||||
if ( empty( $queue ) ) {
|
|
||||||
return array();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove expired items.
|
|
||||||
$now = time();
|
|
||||||
$queue = array_filter(
|
|
||||||
$queue,
|
|
||||||
function ( $item ) use ( $now ) {
|
|
||||||
return ( $now - $item['enqueued'] ) < self::ITEM_TTL_SECONDS;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Grab the first $limit items.
|
|
||||||
$batch = array_splice( $queue, 0, $limit );
|
|
||||||
|
|
||||||
// Save the remaining queue.
|
|
||||||
update_option( self::QUEUE_OPTION_KEY, $queue );
|
|
||||||
|
|
||||||
return $batch;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-enqueue items that failed to post (typically after backoff).
|
|
||||||
*
|
|
||||||
* @param array $items Items from a failed batch.
|
|
||||||
*/
|
|
||||||
public function reenqueue( array $items ): void {
|
|
||||||
$queue = $this->get_queue();
|
|
||||||
|
|
||||||
foreach ( $items as $item ) {
|
|
||||||
$item['attempt'] = ( $item['attempt'] ?? 0 ) + 1;
|
|
||||||
|
|
||||||
// Drop items that have exceeded max retries (3).
|
|
||||||
if ( $item['attempt'] >= 3 ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->warning(
|
|
||||||
sprintf(
|
|
||||||
'Dropping Opik item after %d attempts: %s',
|
|
||||||
$item['attempt'],
|
|
||||||
$item['type']
|
|
||||||
),
|
|
||||||
'opik'
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$queue[] = $item;
|
|
||||||
}
|
|
||||||
|
|
||||||
update_option( self::QUEUE_OPTION_KEY, $queue );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current queue state.
|
|
||||||
*
|
|
||||||
* @return array
|
|
||||||
*/
|
|
||||||
public function get_queue(): array {
|
|
||||||
$queue = get_option( self::QUEUE_OPTION_KEY, array() );
|
|
||||||
return is_array( $queue ) ? $queue : array();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get queue depth (item count).
|
|
||||||
*
|
|
||||||
* @return int
|
|
||||||
*/
|
|
||||||
public function get_queue_depth(): int {
|
|
||||||
return count( $this->get_queue() );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all items from the queue.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function clear(): void {
|
|
||||||
delete_option( self::QUEUE_OPTION_KEY );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a human-readable status string for admin display.
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
public function get_status(): string {
|
|
||||||
$depth = $this->get_queue_depth();
|
|
||||||
if ( 0 === $depth ) {
|
|
||||||
return 'Queue empty';
|
|
||||||
}
|
|
||||||
return sprintf( 'Queue: %d item%s pending', $depth, 1 !== $depth ? 's' : '' );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-request trace context singleton.
|
|
||||||
*
|
|
||||||
* Holds the current trace ID and manages the span stack for a single
|
|
||||||
* article generation. Initialized at the top of the pipeline,
|
|
||||||
* torn down at the end.
|
|
||||||
*
|
|
||||||
* Single-threaded per-request model: one trace per article generation,
|
|
||||||
* child spans per LLM call.
|
|
||||||
*
|
|
||||||
* @see includes/services/opik/class-opik-client.php
|
|
||||||
* @see includes/services/opik/class-opik-span-queue.php
|
|
||||||
*/
|
|
||||||
class PRAutoBlogger_Opik_Trace_Context {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var self|null
|
|
||||||
*/
|
|
||||||
private static ?self $instance = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string Trace ID (UUID).
|
|
||||||
*/
|
|
||||||
private string $trace_id = '';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var float Trace start time (microtime).
|
|
||||||
*/
|
|
||||||
private float $trace_start = 0.0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array Stack of active spans; indexed by span_id.
|
|
||||||
*/
|
|
||||||
private array $span_stack = array();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get or create the current request's trace context.
|
|
||||||
*
|
|
||||||
* @return self
|
|
||||||
*/
|
|
||||||
public static function current(): self {
|
|
||||||
if ( null === self::$instance ) {
|
|
||||||
self::$instance = new self();
|
|
||||||
}
|
|
||||||
return self::$instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize a new trace for this request.
|
|
||||||
*
|
|
||||||
* Generates a UUID and records the start time. Called once at the
|
|
||||||
* top of the article generation pipeline.
|
|
||||||
*
|
|
||||||
* @return string The trace ID (UUID).
|
|
||||||
*/
|
|
||||||
public function init_trace(): string {
|
|
||||||
$this->trace_id = self::generate_uuid7();
|
|
||||||
$this->trace_start = microtime( true );
|
|
||||||
return $this->trace_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current trace ID.
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
public function get_trace_id(): string {
|
|
||||||
return $this->trace_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start a span within the current trace.
|
|
||||||
*
|
|
||||||
* @param array{
|
|
||||||
* name: string,
|
|
||||||
* type: string,
|
|
||||||
* model?: string,
|
|
||||||
* provider?: string,
|
|
||||||
* input?: array,
|
|
||||||
* parent_span_id?: string
|
|
||||||
* } $span_data Span metadata.
|
|
||||||
*
|
|
||||||
* @return string Generated span ID (UUID).
|
|
||||||
*/
|
|
||||||
public function start_span( array $span_data ): string {
|
|
||||||
$span_id = self::generate_uuid7();
|
|
||||||
|
|
||||||
$span = array(
|
|
||||||
'id' => $span_id,
|
|
||||||
'trace_id' => $this->trace_id,
|
|
||||||
'name' => $span_data['name'] ?? 'unknown',
|
|
||||||
'type' => $span_data['type'] ?? 'general',
|
|
||||||
'start_time' => gmdate( 'Y-m-d\TH:i:s\Z' ),
|
|
||||||
'start_time_ms' => microtime( true ),
|
|
||||||
'end_time' => null,
|
|
||||||
'end_time_ms' => null,
|
|
||||||
'input' => $span_data['input'] ?? array(),
|
|
||||||
'output' => array(),
|
|
||||||
'model' => $span_data['model'] ?? null,
|
|
||||||
'provider' => $span_data['provider'] ?? null,
|
|
||||||
'parent_span_id' => $span_data['parent_span_id'] ?? null,
|
|
||||||
'usage' => array(),
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->span_stack[ $span_id ] = $span;
|
|
||||||
return $span_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* End a span and record its output.
|
|
||||||
*
|
|
||||||
* @param string $span_id Span ID from start_span().
|
|
||||||
* @param array{
|
|
||||||
* output?: array,
|
|
||||||
* usage?: array
|
|
||||||
* } $span_data Final span data to merge.
|
|
||||||
*/
|
|
||||||
public function end_span( string $span_id, array $span_data = array() ): void {
|
|
||||||
if ( ! isset( $this->span_stack[ $span_id ] ) ) {
|
|
||||||
PRAutoBlogger_Logger::instance()->warning(
|
|
||||||
sprintf( 'Attempt to end unknown span %s', $span_id ),
|
|
||||||
'opik'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$span = &$this->span_stack[ $span_id ];
|
|
||||||
$span['end_time'] = gmdate( 'Y-m-d\TH:i:s\Z' );
|
|
||||||
$span['end_time_ms'] = microtime( true );
|
|
||||||
$span['output'] = $span_data['output'] ?? array();
|
|
||||||
$span['usage'] = $span_data['usage'] ?? array();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a completed span from the stack.
|
|
||||||
*
|
|
||||||
* @param string $span_id
|
|
||||||
*
|
|
||||||
* @return array|null
|
|
||||||
*/
|
|
||||||
public function get_span( string $span_id ): ?array {
|
|
||||||
return $this->span_stack[ $span_id ] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all spans collected in this trace.
|
|
||||||
*
|
|
||||||
* @return array
|
|
||||||
*/
|
|
||||||
public function get_spans(): array {
|
|
||||||
return $this->span_stack;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finalize and return the trace object with all spans.
|
|
||||||
*
|
|
||||||
* @return array{
|
|
||||||
* id: string,
|
|
||||||
* name: string,
|
|
||||||
* project_name: string,
|
|
||||||
* start_time: string,
|
|
||||||
* end_time: string,
|
|
||||||
* input: array,
|
|
||||||
* output: array,
|
|
||||||
* metadata: array,
|
|
||||||
* spans: array
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function finalize_trace(): array {
|
|
||||||
$trace_end = microtime( true );
|
|
||||||
|
|
||||||
return array(
|
|
||||||
'id' => $this->trace_id,
|
|
||||||
'name' => 'article_generation',
|
|
||||||
'project_name' => get_option( 'prautoblogger_opik_project_name', 'prautoblogger' ),
|
|
||||||
'start_time' => gmdate( 'Y-m-d\TH:i:s\Z', (int) $this->trace_start ),
|
|
||||||
'end_time' => gmdate( 'Y-m-d\TH:i:s\Z', (int) $trace_end ),
|
|
||||||
'input' => array( 'pipeline' => 'article_generation' ),
|
|
||||||
'output' => array( 'spans_count' => count( $this->span_stack ) ),
|
|
||||||
'metadata' => array( 'source' => 'prautoblogger' ),
|
|
||||||
'spans' => $this->span_stack,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tear down the context (clear it for the next request).
|
|
||||||
*
|
|
||||||
* Called at the end of the article generation pipeline.
|
|
||||||
*/
|
|
||||||
public static function teardown(): void {
|
|
||||||
self::$instance = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a UUID version 7 (time-ordered, random).
|
|
||||||
*
|
|
||||||
* Opik requires v7 UUIDs for trace and span IDs. WordPress only provides
|
|
||||||
* wp_generate_uuid4() so we implement v7 here.
|
|
||||||
*
|
|
||||||
* UUID v7 structure (128 bits):
|
|
||||||
* - Bits 0-47 : Unix timestamp in milliseconds (big-endian)
|
|
||||||
* - Bits 48-51 : Version nibble (0x7)
|
|
||||||
* - Bits 52-63 : rand_a (12 random bits)
|
|
||||||
* - Bits 64-65 : Variant (0b10)
|
|
||||||
* - Bits 66-127: rand_b (62 random bits)
|
|
||||||
*
|
|
||||||
* @return string UUID v7 in standard 8-4-4-4-12 hex format.
|
|
||||||
*/
|
|
||||||
private static function generate_uuid7(): string {
|
|
||||||
$ms = (int) ( microtime( true ) * 1000 );
|
|
||||||
$ts_hex = str_pad( dechex( $ms ), 12, '0', STR_PAD_LEFT ); // 48-bit ts -> 12 hex
|
|
||||||
$rand = bin2hex( random_bytes( 10 ) ); // 80 random bits -> 20 hex
|
|
||||||
|
|
||||||
// Group 3: version nibble '7' + 12 random bits (3 hex chars).
|
|
||||||
$g3 = '7' . substr( $rand, 0, 3 );
|
|
||||||
|
|
||||||
// Group 4: variant byte (top 2 bits = 10) + 2 random hex chars.
|
|
||||||
$var_byte = ( hexdec( substr( $rand, 4, 2 ) ) & 0x3f ) | 0x80;
|
|
||||||
$g4 = sprintf( '%02x', $var_byte ) . substr( $rand, 6, 2 );
|
|
||||||
|
|
||||||
// Group 5: 12 remaining random hex chars.
|
|
||||||
$g5 = substr( $rand, 8, 12 );
|
|
||||||
|
|
||||||
return sprintf(
|
|
||||||
'%s-%s-%s-%s-%s',
|
|
||||||
substr( $ts_hex, 0, 8 ), // time_high (32 bits)
|
|
||||||
substr( $ts_hex, 8, 4 ), // time_low (16 bits)
|
|
||||||
$g3,
|
|
||||||
$g4,
|
|
||||||
$g5
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"redefinable-internals": [
|
|
||||||
"time"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
* Plugin Name: PRAutoBlogger
|
* Plugin Name: PRAutoBlogger
|
||||||
* Plugin URI: https://peptiderepo.com/prautoblogger
|
* Plugin URI: https://peptiderepo.com/prautoblogger
|
||||||
* Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics.
|
* Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics.
|
||||||
* Version: 0.16.0
|
* Version: 0.11.0
|
||||||
* Requires at least: 6.0
|
* Requires at least: 6.0
|
||||||
* Requires PHP: 7.4
|
* Requires PHP: 7.4
|
||||||
* Author: PeptideRepo
|
* Author: PeptideRepo
|
||||||
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
| Defined here so every file in the plugin can reference paths, versions,
|
| Defined here so every file in the plugin can reference paths, versions,
|
||||||
| and limits without magic strings.
|
| and limits without magic strings.
|
||||||
*/
|
*/
|
||||||
define( 'PRAUTOBLOGGER_VERSION', '0.16.0' );
|
define( 'PRAUTOBLOGGER_VERSION', '0.11.0' );
|
||||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
|
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
|
||||||
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
|
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
|
||||||
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||||
@@ -66,37 +66,6 @@ define(
|
|||||||
'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX',
|
'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX',
|
||||||
'Style: a single-panel newspaper comic in classic black ink on white paper, simple clean linework like The Far Side or B.C., slightly yellowed newsprint texture, hand-drawn crosshatching for shading, bold panel border, warm and humorous tone, visible in a newspaper comics section. IMPORTANT: Do not render any text, captions, speech bubbles, or words inside the image.'
|
'Style: a single-panel newspaper comic in classic black ink on white paper, simple clean linework like The Far Side or B.C., slightly yellowed newsprint texture, hand-drawn crosshatching for shading, bold panel border, warm and humorous tone, visible in a newspaper comics section. IMPORTANT: Do not render any text, captions, speech bubbles, or words inside the image.'
|
||||||
);
|
);
|
||||||
// Default editorial-illustration prompt template for the image generator.
|
|
||||||
// Updated 2026-05-29 (v0.16.0): pivot from single-panel comic to text-free
|
|
||||||
// editorial scientific illustration. The {{ topic_summary }} token is filled
|
|
||||||
// at build time with a 1-2 sentence topic/mechanism description from the
|
|
||||||
// rewriter LLM; the rest is brand-locked. Replaces the old Style Suffix.
|
|
||||||
// Users may override via Settings -> Images -> Style Template. Per brief A5
|
|
||||||
// the template must contain exactly one {{ topic_summary }} token.
|
|
||||||
define(
|
|
||||||
'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE',
|
|
||||||
"Editorial scientific illustration, flat vector aesthetic, limited palette of\n" .
|
|
||||||
"teal (#1B8A92) and cream (#FAF6EE) with ink (#0D0D0D) line work and accents\n" .
|
|
||||||
"of orange (#FF8A3D) and lime (#7FD600). Subject: {{ topic_summary }}.\n" .
|
|
||||||
"Style reference: Quanta Magazine, The Pudding, scientific editorial illustration.\n" .
|
|
||||||
"Composition: clear focal point, balanced negative space, subtle textural detail.\n" .
|
|
||||||
'Mood: authoritative, clinical, calm. NO TEXT. NO PEOPLE. NO LOGOS. NO LABELS.'
|
|
||||||
);
|
|
||||||
// Opik observability (feature-flag gated).
|
|
||||||
// API credentials must be defined in wp-config.php constants:
|
|
||||||
// - PRAUTOBLOGGER_OPIK_API_KEY: API key for authentication
|
|
||||||
// - PRAUTOBLOGGER_OPIK_WORKSPACE: Opik workspace name (e.g. 'peptiderepo')
|
|
||||||
// - PRAUTOBLOGGER_OPIK_URL_OVERRIDE: Optional; defaults to https://www.comet.com/opik/api/
|
|
||||||
if ( ! defined( 'PRAUTOBLOGGER_OPIK_API_KEY' ) ) {
|
|
||||||
define( 'PRAUTOBLOGGER_OPIK_API_KEY', '' );
|
|
||||||
}
|
|
||||||
if ( ! defined( 'PRAUTOBLOGGER_OPIK_WORKSPACE' ) ) {
|
|
||||||
define( 'PRAUTOBLOGGER_OPIK_WORKSPACE', 'peptiderepo' );
|
|
||||||
}
|
|
||||||
if ( ! defined( 'PRAUTOBLOGGER_OPIK_URL_OVERRIDE' ) ) {
|
|
||||||
define( 'PRAUTOBLOGGER_OPIK_URL_OVERRIDE', 'https://www.comet.com/opik/api/' );
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Autoloader
|
| Autoloader
|
||||||
|
|||||||
@@ -63,24 +63,12 @@ if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL' ) ) {
|
|||||||
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL' ) ) {
|
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL' ) ) {
|
||||||
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'runware:100@1' );
|
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'runware:100@1' );
|
||||||
}
|
}
|
||||||
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER' ) ) {
|
|
||||||
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER', 'runware' );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default image style suffix used by legacy ImagePromptBuilder paths / settings UI.
|
// Default image style suffix used by ImagePromptBuilder and settings UI.
|
||||||
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX' ) ) {
|
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX' ) ) {
|
||||||
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX', 'Style: test infomercial style.' );
|
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX', 'Style: test infomercial style.' );
|
||||||
}
|
}
|
||||||
|
|
||||||
// Editorial style template (v0.16.0): default for prautoblogger_image_style_template.
|
|
||||||
// Must contain exactly one {{ topic_summary }} token (brief A5).
|
|
||||||
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE' ) ) {
|
|
||||||
define(
|
|
||||||
'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE',
|
|
||||||
"Editorial scientific illustration. Subject: {{ topic_summary }}. NO TEXT. NO PEOPLE. NO LOGOS. NO LABELS."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// WordPress database constants used in $wpdb queries.
|
// WordPress database constants used in $wpdb queries.
|
||||||
if ( ! defined( 'ARRAY_A' ) ) {
|
if ( ! defined( 'ARRAY_A' ) ) {
|
||||||
define( 'ARRAY_A', 'ARRAY_A' );
|
define( 'ARRAY_A', 'ARRAY_A' );
|
||||||
|
|||||||
@@ -113,139 +113,4 @@ class CostTrackerTest extends BaseTestCase {
|
|||||||
// If we get here without exception, the method worked.
|
// If we get here without exception, the method worked.
|
||||||
$this->assertTrue( true );
|
$this->assertTrue( true );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression: get_avg_tokens_for_stages must return the documented zero
|
|
||||||
* shape when there is no generation history, without leaving a $wpdb
|
|
||||||
* error behind.
|
|
||||||
*
|
|
||||||
* Load-bearing: asserting $wpdb->last_error === '' alongside the shape
|
|
||||||
* is what would have caught the v0.11.0 column-name drift. The empty-
|
|
||||||
* result fallback silently masked the failure from shape-only tests.
|
|
||||||
*/
|
|
||||||
public function test_get_avg_tokens_returns_zero_shape_when_no_history(): void {
|
|
||||||
$captured_query = '';
|
|
||||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
|
||||||
function ( $sql, ...$args ) use ( &$captured_query ) {
|
|
||||||
$captured_query = $sql;
|
|
||||||
return $sql; // Simulate prepared SQL passthrough.
|
|
||||||
}
|
|
||||||
);
|
|
||||||
// Empty result row — fallback branch.
|
|
||||||
$this->wpdb->method( 'get_row' )->willReturn( null );
|
|
||||||
|
|
||||||
$tracker = new \PRAutoBlogger_Cost_Tracker();
|
|
||||||
$result = $tracker->get_avg_tokens_for_stages( [ 'analysis' ], 30 );
|
|
||||||
|
|
||||||
$this->assertSame(
|
|
||||||
[
|
|
||||||
'avg_prompt_tokens' => 0.0,
|
|
||||||
'avg_completion_tokens' => 0.0,
|
|
||||||
'sample_size' => 0,
|
|
||||||
],
|
|
||||||
$result
|
|
||||||
);
|
|
||||||
$this->assertSame( '', $this->wpdb->last_error, 'wpdb->last_error must be empty after the query' );
|
|
||||||
|
|
||||||
// Schema-drift guard: query must reference canonical columns, not the
|
|
||||||
// pre-v0.15.1 names that don't exist on wp_prautoblogger_generation_log.
|
|
||||||
$this->assertStringContainsString( 'prompt_tokens', $captured_query );
|
|
||||||
$this->assertStringContainsString( 'completion_tokens', $captured_query );
|
|
||||||
$this->assertStringContainsString( 'created_at', $captured_query );
|
|
||||||
$this->assertStringNotContainsString( 'input_tokens', $captured_query );
|
|
||||||
$this->assertStringNotContainsString( 'output_tokens', $captured_query );
|
|
||||||
// The bug query had `AND timestamp >= %d`; canonical query uses
|
|
||||||
// `AND created_at >= %s`. Guard against the column reference, not the
|
|
||||||
// bare word (a SQL comment in future code could legitimately mention
|
|
||||||
// "timestamp" in prose).
|
|
||||||
$this->assertStringNotContainsString( 'timestamp >=', $captured_query );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression: with seeded rows the method returns real averages and the
|
|
||||||
* canonical SELECT aliases (avg_prompt / avg_completion) are extracted
|
|
||||||
* correctly.
|
|
||||||
*/
|
|
||||||
public function test_get_avg_tokens_returns_real_averages_with_seeded_rows(): void {
|
|
||||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
|
||||||
static function ( $sql ) {
|
|
||||||
return $sql;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
// Simulate three rows averaged: prompt_tokens=100, completion_tokens=50.
|
|
||||||
$this->wpdb->method( 'get_row' )->willReturn(
|
|
||||||
[
|
|
||||||
'avg_prompt' => '100.0000',
|
|
||||||
'avg_completion' => '50.0000',
|
|
||||||
'sample_count' => '3',
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
$tracker = new \PRAutoBlogger_Cost_Tracker();
|
|
||||||
$result = $tracker->get_avg_tokens_for_stages( [ 'analysis' ], 30 );
|
|
||||||
|
|
||||||
$this->assertSame( 100.0, $result['avg_prompt_tokens'] );
|
|
||||||
$this->assertSame( 50.0, $result['avg_completion_tokens'] );
|
|
||||||
$this->assertSame( 3, $result['sample_size'] );
|
|
||||||
$this->assertSame( '', $this->wpdb->last_error );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression: the days cutoff must be a MySQL DATETIME string (compared
|
|
||||||
* against the canonical created_at column), not a unix int. Captured via
|
|
||||||
* the prepare() callback so we can inspect the bound parameter.
|
|
||||||
*/
|
|
||||||
public function test_get_avg_tokens_respects_days_cutoff_as_datetime(): void {
|
|
||||||
$captured_args = [];
|
|
||||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
|
||||||
function ( $sql, ...$args ) use ( &$captured_args ) {
|
|
||||||
$captured_args = $args;
|
|
||||||
return $sql;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
$this->wpdb->method( 'get_row' )->willReturn( null );
|
|
||||||
|
|
||||||
$tracker = new \PRAutoBlogger_Cost_Tracker();
|
|
||||||
$tracker->get_avg_tokens_for_stages( [ 'analysis' ], 30 );
|
|
||||||
|
|
||||||
// Exactly one bound param: the DATETIME cutoff.
|
|
||||||
$this->assertCount( 1, $captured_args );
|
|
||||||
$this->assertIsString( $captured_args[0], 'Cutoff must be a DATETIME string, not a unix int' );
|
|
||||||
$this->assertMatchesRegularExpression(
|
|
||||||
'/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/',
|
|
||||||
$captured_args[0]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression: the stage filter is propagated into the SQL. Calling with
|
|
||||||
* a single stage should result in exactly one prepared %s placeholder
|
|
||||||
* for the IN ( … ) list.
|
|
||||||
*/
|
|
||||||
public function test_get_avg_tokens_filters_by_stage(): void {
|
|
||||||
$stage_prepare_calls = 0;
|
|
||||||
$main_query = '';
|
|
||||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
|
||||||
function ( $sql, ...$args ) use ( &$stage_prepare_calls, &$main_query ) {
|
|
||||||
// The method calls $wpdb->prepare twice in two distinct shapes:
|
|
||||||
// 1. per-stage placeholder prep (sql='%s', single arg)
|
|
||||||
// 2. the main SELECT (sql contains 'SELECT', many tokens)
|
|
||||||
if ( strpos( $sql, 'SELECT' ) !== false ) {
|
|
||||||
$main_query = $sql;
|
|
||||||
} else {
|
|
||||||
++$stage_prepare_calls;
|
|
||||||
}
|
|
||||||
return $sql;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
$this->wpdb->method( 'get_row' )->willReturn( null );
|
|
||||||
|
|
||||||
$tracker = new \PRAutoBlogger_Cost_Tracker();
|
|
||||||
$tracker->get_avg_tokens_for_stages( [ 'analysis' ], 30 );
|
|
||||||
|
|
||||||
$this->assertSame( 1, $stage_prepare_calls, 'One stage → one per-stage prepare() call' );
|
|
||||||
$this->assertStringContainsString( 'stage IN', $main_query );
|
|
||||||
$this->assertStringContainsString( 'wp_prautoblogger_generation_log', $main_query );
|
|
||||||
$this->assertSame( '', $this->wpdb->last_error );
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Tests for PRAutoBlogger_Image_Prompt_Builder.
|
* Tests for PRAutoBlogger_Image_Prompt_Builder.
|
||||||
*
|
*
|
||||||
* Validates prompt generation from article content and source data,
|
* Validates prompt generation from article content and source data,
|
||||||
* including editorial-template substitution ({{ topic_summary }}) and HTML stripping.
|
* including style suffix appending and HTML stripping.
|
||||||
*
|
*
|
||||||
* @package PRAutoBlogger\Tests\Core
|
* @package PRAutoBlogger\Tests\Core
|
||||||
*/
|
*/
|
||||||
@@ -68,9 +68,7 @@ class ImagePromptBuilderTest extends BaseTestCase {
|
|||||||
[ 'prompt' => $scene, 'caption' => $caption ] = $builder->build_article_prompt( $article_data );
|
[ 'prompt' => $scene, 'caption' => $caption ] = $builder->build_article_prompt( $article_data );
|
||||||
|
|
||||||
$this->assertStringContainsString( 'How to Train Your Dragon', $scene );
|
$this->assertStringContainsString( 'How to Train Your Dragon', $scene );
|
||||||
// Editorial template wraps the topic summary; token must be substituted.
|
// Style suffix should be appended.
|
||||||
$this->assertStringNotContainsString( '{{ topic_summary }}', $scene );
|
|
||||||
$this->assertStringContainsString( 'Editorial scientific illustration', $scene );
|
|
||||||
$this->assertNotEmpty( $scene );
|
$this->assertNotEmpty( $scene );
|
||||||
$this->assertGreaterThan( 50, strlen( $scene ) );
|
$this->assertGreaterThan( 50, strlen( $scene ) );
|
||||||
$this->assertIsString( $caption );
|
$this->assertIsString( $caption );
|
||||||
|
|||||||
@@ -1,189 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Tests for PRAutoBlogger_Runware_Model_Catalog.
|
|
||||||
*
|
|
||||||
* Validates live sync, fallback logic, cache staleness, and pricing merge.
|
|
||||||
*
|
|
||||||
* @package PRAutoBlogger\Tests\Providers
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace PRAutoBlogger\Tests\Providers;
|
|
||||||
|
|
||||||
use PRAutoBlogger\Tests\BaseTestCase;
|
|
||||||
use Brain\Monkey\Functions;
|
|
||||||
use Brain\Monkey\Actions;
|
|
||||||
|
|
||||||
class RunwareModelCatalogTest extends BaseTestCase {
|
|
||||||
|
|
||||||
protected function setUp(): void {
|
|
||||||
parent::setUp();
|
|
||||||
// Mock WP options for cache simulation.
|
|
||||||
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
|
||||||
static $options = array();
|
|
||||||
return $options[ $key ] ?? $default;
|
|
||||||
} );
|
|
||||||
Functions\when( 'update_option' )->alias( function ( $key, $value ) {
|
|
||||||
static $options = array();
|
|
||||||
$options[ $key ] = $value;
|
|
||||||
return true;
|
|
||||||
} );
|
|
||||||
Functions\when( 'time' )->justReturn( 1704067200 ); // 2024-01-01 00:00:00
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sync with empty or missing API key returns false. */
|
|
||||||
public function test_sync_no_api_key(): void {
|
|
||||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
|
||||||
$result = $catalog->sync();
|
|
||||||
$this->assertFalse( $result );
|
|
||||||
}
|
|
||||||
|
|
||||||
/** get_models returns fallback if no cache and sync fails. */
|
|
||||||
public function test_get_models_fallback_on_sync_failure(): void {
|
|
||||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
|
||||||
$models = $catalog->get_models();
|
|
||||||
|
|
||||||
// Should have the fallback list (15 Runware models).
|
|
||||||
$this->assertIsArray( $models );
|
|
||||||
$this->assertGreaterThan( 0, count( $models ) );
|
|
||||||
|
|
||||||
// Check at least one expected fallback model exists.
|
|
||||||
$schnell_exists = false;
|
|
||||||
foreach ( $models as $model ) {
|
|
||||||
if ( 'runware:100@1' === ( $model['id'] ?? '' ) ) {
|
|
||||||
$schnell_exists = true;
|
|
||||||
$this->assertSame( 'FLUX.1 schnell (Runware)', $model['name'] );
|
|
||||||
$this->assertSame( 'runware', $model['provider'] );
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$this->assertTrue( $schnell_exists, 'Fallback should include FLUX.1 schnell' );
|
|
||||||
}
|
|
||||||
|
|
||||||
/** is_stale returns true if never synced. */
|
|
||||||
public function test_is_stale_never_synced(): void {
|
|
||||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
|
||||||
$this->assertTrue( $catalog->is_stale() );
|
|
||||||
}
|
|
||||||
|
|
||||||
/** get_last_synced_at returns null if never synced. */
|
|
||||||
public function test_get_last_synced_at_never(): void {
|
|
||||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
|
||||||
$this->assertNull( $catalog->get_last_synced_at() );
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fallback list is never empty. */
|
|
||||||
public function test_get_models_never_empty(): void {
|
|
||||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
|
||||||
|
|
||||||
// Even with no cache and failed sync, get_models should return the fallback.
|
|
||||||
$models = $catalog->get_models();
|
|
||||||
$this->assertIsArray( $models );
|
|
||||||
$this->assertNotEmpty( $models );
|
|
||||||
|
|
||||||
// All models should have required fields.
|
|
||||||
foreach ( $models as $model ) {
|
|
||||||
$this->assertArrayHasKey( 'id', $model );
|
|
||||||
$this->assertArrayHasKey( 'name', $model );
|
|
||||||
$this->assertArrayHasKey( 'provider', $model );
|
|
||||||
$this->assertArrayHasKey( 'capabilities', $model );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fallback models have correct pricing. */
|
|
||||||
public function test_fallback_models_have_pricing(): void {
|
|
||||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
|
||||||
$models = $catalog->get_models();
|
|
||||||
|
|
||||||
$schnell = null;
|
|
||||||
$dev = null;
|
|
||||||
foreach ( $models as $model ) {
|
|
||||||
if ( 'runware:100@1' === $model['id'] ) {
|
|
||||||
$schnell = $model;
|
|
||||||
}
|
|
||||||
if ( 'runware:101@1' === $model['id'] ) {
|
|
||||||
$dev = $model;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->assertNotNull( $schnell );
|
|
||||||
$this->assertEqualsWithDelta( 0.0006, $schnell['cost_per_image'], 0.000001 );
|
|
||||||
|
|
||||||
$this->assertNotNull( $dev );
|
|
||||||
$this->assertEqualsWithDelta( 0.02, $dev['cost_per_image'], 0.000001 );
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fallback models all have 'image_generation' capability. */
|
|
||||||
public function test_fallback_models_have_capabilities(): void {
|
|
||||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
|
||||||
$models = $catalog->get_models();
|
|
||||||
|
|
||||||
foreach ( $models as $model ) {
|
|
||||||
$this->assertIsArray( $model['capabilities'] );
|
|
||||||
$this->assertContains( 'image_generation', $model['capabilities'] );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Happy path: successful sync caches models and get_models returns them. */
|
|
||||||
public function test_sync_success_caches_and_returns_models(): void {
|
|
||||||
// The mocked API response body — models must have taskType=imageInference
|
|
||||||
// so normalize_models() doesn't filter them out.
|
|
||||||
$fake_body = json_encode(
|
|
||||||
array(
|
|
||||||
'data' => array(
|
|
||||||
array(
|
|
||||||
'id' => 'test-model-1',
|
|
||||||
'name' => 'Test Model 1',
|
|
||||||
'taskType' => 'imageInference',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'id' => 'test-model-2',
|
|
||||||
'name' => 'Test Model 2',
|
|
||||||
'taskType' => 'imageInference',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$fake_response = array(
|
|
||||||
'response' => array( 'code' => 200 ),
|
|
||||||
'body' => $fake_body,
|
|
||||||
);
|
|
||||||
|
|
||||||
Functions\when( 'wp_remote_post' )->justReturn( $fake_response );
|
|
||||||
// Alias the WP response-parsing helpers so they work against our array.
|
|
||||||
Functions\when( 'wp_remote_retrieve_response_code' )->alias(
|
|
||||||
function ( $r ) { return (int) ( $r['response']['code'] ?? 0 ); }
|
|
||||||
);
|
|
||||||
Functions\when( 'wp_remote_retrieve_body' )->alias(
|
|
||||||
function ( $r ) { return (string) ( $r['body'] ?? '' ); }
|
|
||||||
);
|
|
||||||
Functions\when( 'is_wp_error' )->justReturn( false );
|
|
||||||
|
|
||||||
// Shared options store so get_option/update_option stay in sync
|
|
||||||
// within this test. The API key must be non-empty for sync() to proceed.
|
|
||||||
$store = array( 'prautoblogger_runware_api_key' => 'test-api-key-plain' );
|
|
||||||
Functions\when( 'get_option' )->alias(
|
|
||||||
function ( $key, $default = false ) use ( &$store ) {
|
|
||||||
return $store[ $key ] ?? $default;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
Functions\when( 'update_option' )->alias(
|
|
||||||
function ( $key, $value ) use ( &$store ) {
|
|
||||||
$store[ $key ] = $value;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sync should succeed: API key present, HTTP 200, 2 imageInference models.
|
|
||||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
|
||||||
$result = $catalog->sync();
|
|
||||||
$this->assertTrue( $result, 'Sync should return true on HTTP 200 with valid models' );
|
|
||||||
|
|
||||||
// get_models() should now return the synced list from cache.
|
|
||||||
$models = $catalog->get_models();
|
|
||||||
$this->assertIsArray( $models );
|
|
||||||
$this->assertCount( 2, $models );
|
|
||||||
$this->assertSame( 'test-model-1', $models[0]['id'] );
|
|
||||||
$this->assertSame( 'test-model-2', $models[1]['id'] );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,18 +16,6 @@ use PRAutoBlogger\Tests\BaseTestCase;
|
|||||||
|
|
||||||
class ImageModelRegistryTest extends BaseTestCase {
|
class ImageModelRegistryTest extends BaseTestCase {
|
||||||
|
|
||||||
/**
|
|
||||||
* Stub WP cache functions so the catalog class falls back to the
|
|
||||||
* hardcoded model list without hitting get_option or update_option.
|
|
||||||
*/
|
|
||||||
protected function setUp(): void {
|
|
||||||
parent::setUp();
|
|
||||||
// Catalog checks for cached data; return false → falls back to hardcoded list.
|
|
||||||
\Brain\Monkey\Functions\when( 'get_option' )->justReturn( false );
|
|
||||||
// Catalog writes cache on sync; stub so it doesn't throw in tests.
|
|
||||||
\Brain\Monkey\Functions\when( 'update_option' )->justReturn( true );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every entry returned by get_models() must carry a non-empty id and
|
* Every entry returned by get_models() must carry a non-empty id and
|
||||||
* a provider of 'runware' or 'openrouter' (Cloudflare Workers AI was
|
* a provider of 'runware' or 'openrouter' (Cloudflare Workers AI was
|
||||||
@@ -111,56 +99,4 @@ class ImageModelRegistryTest extends BaseTestCase {
|
|||||||
\PRAutoBlogger_Image_Model_Registry::provider_for( '' )
|
\PRAutoBlogger_Image_Model_Registry::provider_for( '' )
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Test that runware:400@4 resolves to runware provider.
|
|
||||||
*/
|
|
||||||
public function test_provider_for_runware_400_at_4(): void {
|
|
||||||
$this->assertSame(
|
|
||||||
'runware',
|
|
||||||
\PRAutoBlogger_Image_Model_Registry::provider_for( 'runware:400@4' )
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test that runware:glm-image@0 resolves to runware provider.
|
|
||||||
*/
|
|
||||||
public function test_provider_for_runware_glm_image(): void {
|
|
||||||
$this->assertSame(
|
|
||||||
'runware',
|
|
||||||
\PRAutoBlogger_Image_Model_Registry::provider_for( 'runware:glm-image@0' )
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test that openai/gpt-5.4-image-2 resolves to openrouter provider.
|
|
||||||
*/
|
|
||||||
public function test_provider_for_gpt_5_4_image_2(): void {
|
|
||||||
$this->assertSame(
|
|
||||||
'openrouter',
|
|
||||||
\PRAutoBlogger_Image_Model_Registry::provider_for( 'openai/gpt-5.4-image-2' )
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test that get_models() returns 21 models.
|
|
||||||
*/
|
|
||||||
public function test_get_models_returns_21_models(): void {
|
|
||||||
$models = \PRAutoBlogger_Image_Model_Registry::get_models();
|
|
||||||
$this->assertCount( 21, $models );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test that every model has required non-empty fields.
|
|
||||||
*/
|
|
||||||
public function test_every_model_has_required_fields(): void {
|
|
||||||
$models = \PRAutoBlogger_Image_Model_Registry::get_models();
|
|
||||||
foreach ( $models as $model ) {
|
|
||||||
$this->assertNotEmpty( $model['id'] ?? '', 'Model missing id' );
|
|
||||||
$this->assertNotEmpty( $model['name'] ?? '', 'Model missing name' );
|
|
||||||
$this->assertNotEmpty( $model['provider'] ?? '', 'Model missing provider' );
|
|
||||||
$this->assertTrue( isset( $model['cost_per_image'] ), 'Model missing cost_per_image' );
|
|
||||||
$this->assertIsNumeric( $model['cost_per_image'], 'cost_per_image is not numeric' );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,281 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unit tests for PRAutoBlogger_Opik_Client.
|
|
||||||
*
|
|
||||||
* @see includes/services/opik/class-opik-client.php
|
|
||||||
*/
|
|
||||||
namespace PRAutoBlogger\Tests\Services;
|
|
||||||
|
|
||||||
use PRAutoBlogger\Tests\BaseTestCase;
|
|
||||||
use Brain\Monkey\Functions;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @group opik
|
|
||||||
* @group services
|
|
||||||
*/
|
|
||||||
class TestOpikClient extends BaseTestCase {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test constructor initializes properties correctly.
|
|
||||||
*/
|
|
||||||
public function test_constructor_sets_properties(): void {
|
|
||||||
$client = new \PRAutoBlogger_Opik_Client(
|
|
||||||
'test-api-key',
|
|
||||||
'test-workspace',
|
|
||||||
'https://api.example.com/opik/',
|
|
||||||
45
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->assertInstanceOf( \PRAutoBlogger_Opik_Client::class, $client );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test create_trace sends correct payload to endpoint.
|
|
||||||
*/
|
|
||||||
public function test_create_trace_sends_payload(): void {
|
|
||||||
$client = new \PRAutoBlogger_Opik_Client(
|
|
||||||
'test-key',
|
|
||||||
'test-workspace'
|
|
||||||
);
|
|
||||||
|
|
||||||
$trace_payload = array(
|
|
||||||
'id' => 'trace-123',
|
|
||||||
'name' => 'test_trace',
|
|
||||||
'project_name' => 'prautoblogger',
|
|
||||||
'start_time' => '2026-04-23T10:00:00Z',
|
|
||||||
'end_time' => '2026-04-23T10:01:00Z',
|
|
||||||
'input' => array( 'topic' => 'test' ),
|
|
||||||
'output' => array( 'status' => 'ok' ),
|
|
||||||
'metadata' => array(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mock wp_remote_post to return success.
|
|
||||||
Functions\when( 'wp_remote_post' )->justReturn(
|
|
||||||
array(
|
|
||||||
'response' => array( 'code' => 200 ),
|
|
||||||
'body' => wp_json_encode( array( 'id' => 'trace-123' ) ),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
|
|
||||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( wp_json_encode( array( 'id' => 'trace-123' ) ) );
|
|
||||||
Functions\when( 'wp_json_encode' )->alias( 'json_encode' );
|
|
||||||
Functions\when( 'is_wp_error' )->justReturn( false );
|
|
||||||
|
|
||||||
$result = $client->create_trace( $trace_payload );
|
|
||||||
|
|
||||||
$this->assertIsArray( $result );
|
|
||||||
$this->assertArrayHasKey( 'id', $result );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test create_span sends span with correct structure.
|
|
||||||
*/
|
|
||||||
public function test_create_span_sends_payload(): void {
|
|
||||||
$client = new \PRAutoBlogger_Opik_Client(
|
|
||||||
'test-key',
|
|
||||||
'test-workspace'
|
|
||||||
);
|
|
||||||
|
|
||||||
$span_payload = array(
|
|
||||||
'id' => 'span-456',
|
|
||||||
'trace_id' => 'trace-123',
|
|
||||||
'name' => 'draft_generation',
|
|
||||||
'type' => 'llm',
|
|
||||||
'start_time' => '2026-04-23T10:00:00Z',
|
|
||||||
'end_time' => '2026-04-23T10:00:15Z',
|
|
||||||
'input' => array( 'prompt_hash' => 'sha256-abc' ),
|
|
||||||
'output' => array( 'response' => 'test' ),
|
|
||||||
'model' => 'gpt-4',
|
|
||||||
'provider' => 'openrouter',
|
|
||||||
'usage' => array(
|
|
||||||
'prompt_tokens' => 100,
|
|
||||||
'completion_tokens' => 50,
|
|
||||||
'total_tokens' => 150,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
Functions\when( 'wp_remote_post' )->justReturn(
|
|
||||||
array(
|
|
||||||
'response' => array( 'code' => 200 ),
|
|
||||||
'body' => wp_json_encode( array( 'id' => 'span-456' ) ),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
|
|
||||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( wp_json_encode( array( 'id' => 'span-456' ) ) );
|
|
||||||
Functions\when( 'wp_json_encode' )->alias( 'json_encode' );
|
|
||||||
Functions\when( 'is_wp_error' )->justReturn( false );
|
|
||||||
|
|
||||||
$result = $client->create_span( $span_payload );
|
|
||||||
|
|
||||||
$this->assertIsArray( $result );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test batch_create_spans respects the 100-item limit.
|
|
||||||
*/
|
|
||||||
public function test_batch_create_spans_truncates_over_100(): void {
|
|
||||||
$client = new \PRAutoBlogger_Opik_Client(
|
|
||||||
'test-key',
|
|
||||||
'test-workspace'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create 150 dummy spans.
|
|
||||||
$spans = array_map(
|
|
||||||
function( $i ) {
|
|
||||||
return array(
|
|
||||||
'id' => "span-$i",
|
|
||||||
'trace_id' => 'trace-123',
|
|
||||||
'name' => 'test',
|
|
||||||
'type' => 'llm',
|
|
||||||
'start_time' => '2026-04-23T10:00:00Z',
|
|
||||||
'end_time' => '2026-04-23T10:00:01Z',
|
|
||||||
'input' => array(),
|
|
||||||
'output' => array(),
|
|
||||||
'model' => 'gpt-4',
|
|
||||||
'provider' => 'openrouter',
|
|
||||||
'usage' => array( 'total_tokens' => 10 ),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
range( 1, 150 )
|
|
||||||
);
|
|
||||||
|
|
||||||
Functions\when( 'wp_remote_post' )->justReturn(
|
|
||||||
array(
|
|
||||||
'response' => array( 'code' => 200 ),
|
|
||||||
'body' => wp_json_encode( array( 'inserted' => 100 ) ),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
|
|
||||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( wp_json_encode( array( 'inserted' => 100 ) ) );
|
|
||||||
Functions\when( 'wp_json_encode' )->alias( 'json_encode' );
|
|
||||||
Functions\when( 'is_wp_error' )->justReturn( false );
|
|
||||||
|
|
||||||
// Should truncate and succeed.
|
|
||||||
$result = $client->batch_create_spans( $spans );
|
|
||||||
|
|
||||||
$this->assertIsArray( $result );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test that HTTP 500 errors trigger retry logic.
|
|
||||||
*/
|
|
||||||
public function test_retry_on_500_error(): void {
|
|
||||||
$client = new \PRAutoBlogger_Opik_Client(
|
|
||||||
'test-key',
|
|
||||||
'test-workspace',
|
|
||||||
'https://api.example.com/opik/',
|
|
||||||
1 // 1-second timeout to make test fast
|
|
||||||
);
|
|
||||||
|
|
||||||
$call_count = 0;
|
|
||||||
Functions\when( 'wp_remote_post' )->alias(
|
|
||||||
function() use ( &$call_count ) {
|
|
||||||
++$call_count;
|
|
||||||
if ( $call_count < 3 ) {
|
|
||||||
// First two calls return 500.
|
|
||||||
return array(
|
|
||||||
'response' => array( 'code' => 500 ),
|
|
||||||
'body' => 'Server error',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Third call succeeds.
|
|
||||||
return array(
|
|
||||||
'response' => array( 'code' => 200 ),
|
|
||||||
'body' => wp_json_encode( array( 'ok' => true ) ),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Functions\when( 'wp_remote_retrieve_response_code' )->alias(
|
|
||||||
function() use ( &$call_count ) {
|
|
||||||
return $call_count < 3 ? 500 : 200;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Functions\when( 'wp_remote_retrieve_body' )->alias(
|
|
||||||
function() use ( &$call_count ) {
|
|
||||||
return $call_count < 3 ? 'Server error' : wp_json_encode( array( 'ok' => true ) );
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Functions\when( 'wp_json_encode' )->alias( 'json_encode' );
|
|
||||||
Functions\when( 'is_wp_error' )->justReturn( false );
|
|
||||||
|
|
||||||
$result = $client->create_trace( array( 'id' => 'test', 'name' => 'test' ) );
|
|
||||||
|
|
||||||
// Should eventually succeed on 3rd attempt.
|
|
||||||
$this->assertIsArray( $result );
|
|
||||||
$this->assertEquals( 3, $call_count );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test that HTTP 400 errors don't retry.
|
|
||||||
*/
|
|
||||||
public function test_no_retry_on_400_error(): void {
|
|
||||||
$client = new \PRAutoBlogger_Opik_Client(
|
|
||||||
'test-key',
|
|
||||||
'test-workspace'
|
|
||||||
);
|
|
||||||
|
|
||||||
$call_count = 0;
|
|
||||||
Functions\when( 'wp_remote_post' )->alias(
|
|
||||||
function() use ( &$call_count ) {
|
|
||||||
++$call_count;
|
|
||||||
return array(
|
|
||||||
'response' => array( 'code' => 400 ),
|
|
||||||
'body' => wp_json_encode( array( 'error' => 'Bad request' ) ),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 400 );
|
|
||||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( wp_json_encode( array( 'error' => 'Bad request' ) ) );
|
|
||||||
Functions\when( 'wp_json_encode' )->alias( 'json_encode' );
|
|
||||||
Functions\when( 'is_wp_error' )->justReturn( false );
|
|
||||||
|
|
||||||
$result = $client->create_trace( array( 'id' => 'test', 'name' => 'test' ) );
|
|
||||||
|
|
||||||
// Should fail on first attempt, not retry.
|
|
||||||
$this->assertNull( $result );
|
|
||||||
$this->assertEquals( 1, $call_count );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test that API key and workspace are included in headers.
|
|
||||||
*/
|
|
||||||
public function test_headers_include_credentials(): void {
|
|
||||||
$client = new \PRAutoBlogger_Opik_Client(
|
|
||||||
'secret-key-12345',
|
|
||||||
'my-workspace'
|
|
||||||
);
|
|
||||||
|
|
||||||
$captured_headers = null;
|
|
||||||
Functions\when( 'wp_remote_post' )->alias(
|
|
||||||
function( $url, $args ) use ( &$captured_headers ) {
|
|
||||||
$captured_headers = $args['headers'] ?? array();
|
|
||||||
return array(
|
|
||||||
'response' => array( 'code' => 200 ),
|
|
||||||
'body' => wp_json_encode( array() ),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
|
|
||||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( wp_json_encode( array() ) );
|
|
||||||
Functions\when( 'wp_json_encode' )->alias( 'json_encode' );
|
|
||||||
Functions\when( 'is_wp_error' )->justReturn( false );
|
|
||||||
|
|
||||||
$client->create_trace( array( 'id' => 'test', 'name' => 'test' ) );
|
|
||||||
|
|
||||||
$this->assertIsArray( $captured_headers );
|
|
||||||
$this->assertArrayHasKey( 'Authorization', $captured_headers );
|
|
||||||
$this->assertEquals( 'secret-key-12345', $captured_headers['Authorization'] );
|
|
||||||
$this->assertArrayHasKey( 'Comet-Workspace', $captured_headers );
|
|
||||||
$this->assertEquals( 'my-workspace', $captured_headers['Comet-Workspace'] );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unit tests for PRAutoBlogger_Opik_Dispatcher.
|
|
||||||
*
|
|
||||||
* @see includes/services/opik/class-opik-dispatcher.php
|
|
||||||
*/
|
|
||||||
namespace PRAutoBlogger\Tests\Services;
|
|
||||||
|
|
||||||
use PRAutoBlogger\Tests\BaseTestCase;
|
|
||||||
use Brain\Monkey\Functions;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @group opik
|
|
||||||
* @group services
|
|
||||||
*/
|
|
||||||
class TestOpikDispatcher extends BaseTestCase {
|
|
||||||
|
|
||||||
private array $option_store = array();
|
|
||||||
|
|
||||||
protected function setUp(): void {
|
|
||||||
parent::setUp();
|
|
||||||
|
|
||||||
// Mock WP options.
|
|
||||||
Functions\when( 'get_option' )->alias(
|
|
||||||
function( $option, $default = false ) {
|
|
||||||
return $this->option_store[ $option ] ?? $default;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Functions\when( 'update_option' )->alias(
|
|
||||||
function( $option, $value ) {
|
|
||||||
$this->option_store[ $option ] = $value;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Functions\when( 'delete_option' )->alias(
|
|
||||||
function( $option ) {
|
|
||||||
unset( $this->option_store[ $option ] );
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test dispatcher skips if Opik is not enabled.
|
|
||||||
*/
|
|
||||||
public function test_dispatch_skips_if_disabled(): void {
|
|
||||||
$client = $this->create_mock_client();
|
|
||||||
$queue = $this->create_mock_queue();
|
|
||||||
|
|
||||||
// Queue has items but feature is disabled.
|
|
||||||
$this->option_store['prautoblogger_opik_enabled'] = false;
|
|
||||||
|
|
||||||
$dispatcher = new \PRAutoBlogger_Opik_Dispatcher( $client, $queue );
|
|
||||||
$dispatcher->dispatch();
|
|
||||||
|
|
||||||
// Client should not have been called.
|
|
||||||
$this->assertTrue( true );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test dispatch() batches and posts spans.
|
|
||||||
*/
|
|
||||||
public function test_dispatch_posts_spans(): void {
|
|
||||||
// Enable Opik and set credentials.
|
|
||||||
$this->option_store['prautoblogger_opik_enabled'] = true;
|
|
||||||
|
|
||||||
// Mock constants.
|
|
||||||
if ( ! defined( 'PRAUTOBLOGGER_OPIK_API_KEY' ) ) {
|
|
||||||
define( 'PRAUTOBLOGGER_OPIK_API_KEY', 'test-key' );
|
|
||||||
}
|
|
||||||
if ( ! defined( 'PRAUTOBLOGGER_OPIK_WORKSPACE' ) ) {
|
|
||||||
define( 'PRAUTOBLOGGER_OPIK_WORKSPACE', 'test-workspace' );
|
|
||||||
}
|
|
||||||
|
|
||||||
$posted_spans = null;
|
|
||||||
|
|
||||||
$client = $this->getMockBuilder( \PRAutoBlogger_Opik_Client::class )
|
|
||||||
->disableOriginalConstructor()
|
|
||||||
->onlyMethods( array( 'batch_create_spans', 'batch_create_traces' ) )
|
|
||||||
->getMock();
|
|
||||||
|
|
||||||
$client->expects( $this->once() )
|
|
||||||
->method( 'batch_create_spans' )
|
|
||||||
->willReturnCallback( function( $spans ) use ( &$posted_spans ) {
|
|
||||||
$posted_spans = $spans;
|
|
||||||
return array( 'inserted' => count( $spans ) );
|
|
||||||
} );
|
|
||||||
|
|
||||||
$queue = $this->getMockBuilder( \PRAutoBlogger_Opik_Span_Queue::class )
|
|
||||||
->disableOriginalConstructor()
|
|
||||||
->onlyMethods( array( 'dequeue', 'get_queue_depth' ) )
|
|
||||||
->getMock();
|
|
||||||
|
|
||||||
$queue->expects( $this->once() )
|
|
||||||
->method( 'dequeue' )
|
|
||||||
->with( 100 )
|
|
||||||
->willReturn(
|
|
||||||
array(
|
|
||||||
array(
|
|
||||||
'type' => 'span',
|
|
||||||
'payload' => array( 'id' => 'span-1', 'name' => 'test' ),
|
|
||||||
'attempt' => 0,
|
|
||||||
'enqueued' => time(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$queue->method( 'get_queue_depth' )->willReturn( 0 );
|
|
||||||
|
|
||||||
$dispatcher = new \PRAutoBlogger_Opik_Dispatcher( $client, $queue );
|
|
||||||
$dispatcher->dispatch();
|
|
||||||
|
|
||||||
$this->assertNotNull( $posted_spans );
|
|
||||||
$this->assertCount( 1, $posted_spans );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test dispatcher handles empty queue gracefully.
|
|
||||||
*/
|
|
||||||
public function test_dispatch_handles_empty_queue(): void {
|
|
||||||
$this->option_store['prautoblogger_opik_enabled'] = true;
|
|
||||||
|
|
||||||
if ( ! defined( 'PRAUTOBLOGGER_OPIK_API_KEY' ) ) {
|
|
||||||
define( 'PRAUTOBLOGGER_OPIK_API_KEY', 'test-key' );
|
|
||||||
}
|
|
||||||
if ( ! defined( 'PRAUTOBLOGGER_OPIK_WORKSPACE' ) ) {
|
|
||||||
define( 'PRAUTOBLOGGER_OPIK_WORKSPACE', 'test-workspace' );
|
|
||||||
}
|
|
||||||
|
|
||||||
$client = $this->create_mock_client();
|
|
||||||
$queue = $this->create_mock_queue( array() );
|
|
||||||
|
|
||||||
$dispatcher = new \PRAutoBlogger_Opik_Dispatcher( $client, $queue );
|
|
||||||
$dispatcher->dispatch();
|
|
||||||
|
|
||||||
// Should not error.
|
|
||||||
$this->assertTrue( true );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test dispatcher separates spans and traces.
|
|
||||||
*/
|
|
||||||
public function test_dispatch_separates_types(): void {
|
|
||||||
$this->option_store['prautoblogger_opik_enabled'] = true;
|
|
||||||
|
|
||||||
if ( ! defined( 'PRAUTOBLOGGER_OPIK_API_KEY' ) ) {
|
|
||||||
define( 'PRAUTOBLOGGER_OPIK_API_KEY', 'test-key' );
|
|
||||||
}
|
|
||||||
if ( ! defined( 'PRAUTOBLOGGER_OPIK_WORKSPACE' ) ) {
|
|
||||||
define( 'PRAUTOBLOGGER_OPIK_WORKSPACE', 'test-workspace' );
|
|
||||||
}
|
|
||||||
|
|
||||||
$posted_traces = null;
|
|
||||||
$posted_spans = null;
|
|
||||||
|
|
||||||
$client = $this->getMockBuilder( \PRAutoBlogger_Opik_Client::class )
|
|
||||||
->disableOriginalConstructor()
|
|
||||||
->onlyMethods( array( 'batch_create_traces', 'batch_create_spans' ) )
|
|
||||||
->getMock();
|
|
||||||
|
|
||||||
$client->method( 'batch_create_traces' )->willReturnCallback(
|
|
||||||
function( $traces ) use ( &$posted_traces ) {
|
|
||||||
$posted_traces = $traces;
|
|
||||||
return array( 'inserted' => count( $traces ) );
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
$client->method( 'batch_create_spans' )->willReturnCallback(
|
|
||||||
function( $spans ) use ( &$posted_spans ) {
|
|
||||||
$posted_spans = $spans;
|
|
||||||
return array( 'inserted' => count( $spans ) );
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
$queue = $this->getMockBuilder( \PRAutoBlogger_Opik_Span_Queue::class )
|
|
||||||
->disableOriginalConstructor()
|
|
||||||
->onlyMethods( array( 'dequeue', 'get_queue_depth' ) )
|
|
||||||
->getMock();
|
|
||||||
|
|
||||||
$queue->method( 'dequeue' )->willReturn(
|
|
||||||
array(
|
|
||||||
array(
|
|
||||||
'type' => 'trace',
|
|
||||||
'payload' => array( 'id' => 'trace-1' ),
|
|
||||||
'attempt' => 0,
|
|
||||||
'enqueued' => time(),
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'type' => 'span',
|
|
||||||
'payload' => array( 'id' => 'span-1' ),
|
|
||||||
'attempt' => 0,
|
|
||||||
'enqueued' => time(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$queue->method( 'get_queue_depth' )->willReturn( 0 );
|
|
||||||
|
|
||||||
$dispatcher = new \PRAutoBlogger_Opik_Dispatcher( $client, $queue );
|
|
||||||
$dispatcher->dispatch();
|
|
||||||
|
|
||||||
$this->assertNotNull( $posted_traces );
|
|
||||||
$this->assertCount( 1, $posted_traces );
|
|
||||||
$this->assertNotNull( $posted_spans );
|
|
||||||
$this->assertCount( 1, $posted_spans );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to create a mock client.
|
|
||||||
*/
|
|
||||||
private function create_mock_client() {
|
|
||||||
$client = $this->getMockBuilder( \PRAutoBlogger_Opik_Client::class )
|
|
||||||
->disableOriginalConstructor()
|
|
||||||
->onlyMethods( array( 'batch_create_spans', 'batch_create_traces' ) )
|
|
||||||
->getMock();
|
|
||||||
|
|
||||||
$client->method( 'batch_create_spans' )->willReturn( array() );
|
|
||||||
$client->method( 'batch_create_traces' )->willReturn( array() );
|
|
||||||
|
|
||||||
return $client;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to create a mock queue.
|
|
||||||
*/
|
|
||||||
private function create_mock_queue( array $items = array() ) {
|
|
||||||
$queue = $this->getMockBuilder( \PRAutoBlogger_Opik_Span_Queue::class )
|
|
||||||
->disableOriginalConstructor()
|
|
||||||
->onlyMethods( array( 'dequeue', 'get_queue_depth' ) )
|
|
||||||
->getMock();
|
|
||||||
|
|
||||||
$queue->method( 'dequeue' )->willReturn( $items );
|
|
||||||
$queue->method( 'get_queue_depth' )->willReturn( count( $items ) );
|
|
||||||
|
|
||||||
return $queue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unit tests for PRAutoBlogger_Opik_Span_Queue.
|
|
||||||
*
|
|
||||||
* @see includes/services/opik/class-opik-span-queue.php
|
|
||||||
*/
|
|
||||||
namespace PRAutoBlogger\Tests\Services;
|
|
||||||
|
|
||||||
use PRAutoBlogger\Tests\BaseTestCase;
|
|
||||||
use Brain\Monkey\Functions;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @group opik
|
|
||||||
* @group services
|
|
||||||
*/
|
|
||||||
class TestOpikSpanQueue extends BaseTestCase {
|
|
||||||
|
|
||||||
private array $option_store = array();
|
|
||||||
|
|
||||||
protected function setUp(): void {
|
|
||||||
parent::setUp();
|
|
||||||
|
|
||||||
// Mock WP options functions.
|
|
||||||
Functions\when( 'get_option' )->alias(
|
|
||||||
function( $option, $default = false ) {
|
|
||||||
return $this->option_store[ $option ] ?? $default;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Functions\when( 'update_option' )->alias(
|
|
||||||
function( $option, $value ) {
|
|
||||||
$this->option_store[ $option ] = $value;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Functions\when( 'delete_option' )->alias(
|
|
||||||
function( $option ) {
|
|
||||||
unset( $this->option_store[ $option ] );
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test enqueue() adds item to queue.
|
|
||||||
*/
|
|
||||||
public function test_enqueue_adds_item(): void {
|
|
||||||
$queue = new \PRAutoBlogger_Opik_Span_Queue();
|
|
||||||
|
|
||||||
$payload = array(
|
|
||||||
'id' => 'span-1',
|
|
||||||
'name' => 'test',
|
|
||||||
'trace_id' => 'trace-1',
|
|
||||||
);
|
|
||||||
|
|
||||||
$success = $queue->enqueue( $payload, 'span' );
|
|
||||||
|
|
||||||
$this->assertTrue( $success );
|
|
||||||
$this->assertEquals( 1, $queue->get_queue_depth() );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test enqueue() respects max depth limit.
|
|
||||||
*/
|
|
||||||
public function test_enqueue_respects_max_depth(): void {
|
|
||||||
$queue = new \PRAutoBlogger_Opik_Span_Queue();
|
|
||||||
|
|
||||||
// Fill queue to max.
|
|
||||||
for ( $i = 0; $i < 1000; ++$i ) {
|
|
||||||
$success = $queue->enqueue(
|
|
||||||
array( 'id' => "span-$i", 'name' => 'test' ),
|
|
||||||
'span'
|
|
||||||
);
|
|
||||||
if ( ! $success ) {
|
|
||||||
// Queue full.
|
|
||||||
$this->assertEquals( 1000, $queue->get_queue_depth() );
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we got here, queue filled successfully.
|
|
||||||
$this->assertEquals( 1000, $queue->get_queue_depth() );
|
|
||||||
|
|
||||||
// Try to add one more — should fail.
|
|
||||||
$success = $queue->enqueue(
|
|
||||||
array( 'id' => 'overflow', 'name' => 'test' ),
|
|
||||||
'span'
|
|
||||||
);
|
|
||||||
$this->assertFalse( $success );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test dequeue() returns items in order.
|
|
||||||
*/
|
|
||||||
public function test_dequeue_returns_items(): void {
|
|
||||||
$queue = new \PRAutoBlogger_Opik_Span_Queue();
|
|
||||||
|
|
||||||
$queue->enqueue( array( 'id' => 'span-1' ), 'span' );
|
|
||||||
$queue->enqueue( array( 'id' => 'span-2' ), 'span' );
|
|
||||||
$queue->enqueue( array( 'id' => 'span-3' ), 'span' );
|
|
||||||
|
|
||||||
$batch = $queue->dequeue( 10 );
|
|
||||||
|
|
||||||
$this->assertCount( 3, $batch );
|
|
||||||
$this->assertEquals( 'span-1', $batch[0]['payload']['id'] );
|
|
||||||
$this->assertEquals( 'span-2', $batch[1]['payload']['id'] );
|
|
||||||
$this->assertEquals( 'span-3', $batch[2]['payload']['id'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test dequeue() respects limit parameter.
|
|
||||||
*/
|
|
||||||
public function test_dequeue_respects_limit(): void {
|
|
||||||
$queue = new \PRAutoBlogger_Opik_Span_Queue();
|
|
||||||
|
|
||||||
for ( $i = 1; $i <= 50; ++$i ) {
|
|
||||||
$queue->enqueue( array( 'id' => "span-$i" ), 'span' );
|
|
||||||
}
|
|
||||||
|
|
||||||
$batch = $queue->dequeue( 10 );
|
|
||||||
|
|
||||||
$this->assertCount( 10, $batch );
|
|
||||||
$this->assertEquals( 40, $queue->get_queue_depth() );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test reenqueue() increments attempt counter.
|
|
||||||
*/
|
|
||||||
public function test_reenqueue_increments_attempt(): void {
|
|
||||||
$queue = new \PRAutoBlogger_Opik_Span_Queue();
|
|
||||||
|
|
||||||
$items = array(
|
|
||||||
array(
|
|
||||||
'type' => 'span',
|
|
||||||
'payload' => array( 'id' => 'span-1' ),
|
|
||||||
'attempt' => 0,
|
|
||||||
'enqueued' => time(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
$queue->reenqueue( $items );
|
|
||||||
|
|
||||||
$batch = $queue->dequeue( 10 );
|
|
||||||
$this->assertEquals( 1, $batch[0]['attempt'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test reenqueue() drops items after max retries.
|
|
||||||
*/
|
|
||||||
public function test_reenqueue_drops_after_max_attempts(): void {
|
|
||||||
$queue = new \PRAutoBlogger_Opik_Span_Queue();
|
|
||||||
|
|
||||||
$items = array(
|
|
||||||
array(
|
|
||||||
'type' => 'span',
|
|
||||||
'payload' => array( 'id' => 'span-1' ),
|
|
||||||
'attempt' => 3, // Already at max.
|
|
||||||
'enqueued' => time(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
$queue->reenqueue( $items );
|
|
||||||
|
|
||||||
// Item should be dropped, queue should be empty.
|
|
||||||
$this->assertEquals( 0, $queue->get_queue_depth() );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test dequeue() removes expired items (TTL > 12 hours).
|
|
||||||
*/
|
|
||||||
public function test_dequeue_removes_expired_items(): void {
|
|
||||||
$queue = new \PRAutoBlogger_Opik_Span_Queue();
|
|
||||||
|
|
||||||
$now = time();
|
|
||||||
|
|
||||||
// Add a fresh item.
|
|
||||||
$fresh = array(
|
|
||||||
'type' => 'span',
|
|
||||||
'payload' => array( 'id' => 'fresh' ),
|
|
||||||
'enqueued' => $now,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add an expired item (13 hours old).
|
|
||||||
$expired = array(
|
|
||||||
'type' => 'span',
|
|
||||||
'payload' => array( 'id' => 'expired' ),
|
|
||||||
'enqueued' => $now - 46800, // 13 hours in seconds.
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->option_store['prautoblogger_opik_queue'] = array( $fresh, $expired );
|
|
||||||
|
|
||||||
$batch = $queue->dequeue( 10 );
|
|
||||||
|
|
||||||
// Only fresh item should be returned.
|
|
||||||
$this->assertCount( 1, $batch );
|
|
||||||
$this->assertEquals( 'fresh', $batch[0]['payload']['id'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test get_queue_depth() returns item count.
|
|
||||||
*/
|
|
||||||
public function test_get_queue_depth(): void {
|
|
||||||
$queue = new \PRAutoBlogger_Opik_Span_Queue();
|
|
||||||
|
|
||||||
$this->assertEquals( 0, $queue->get_queue_depth() );
|
|
||||||
|
|
||||||
$queue->enqueue( array( 'id' => 'span-1' ), 'span' );
|
|
||||||
$this->assertEquals( 1, $queue->get_queue_depth() );
|
|
||||||
|
|
||||||
$queue->enqueue( array( 'id' => 'span-2' ), 'span' );
|
|
||||||
$this->assertEquals( 2, $queue->get_queue_depth() );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test clear() empties the queue.
|
|
||||||
*/
|
|
||||||
public function test_clear_empties_queue(): void {
|
|
||||||
$queue = new \PRAutoBlogger_Opik_Span_Queue();
|
|
||||||
|
|
||||||
$queue->enqueue( array( 'id' => 'span-1' ), 'span' );
|
|
||||||
$queue->enqueue( array( 'id' => 'span-2' ), 'span' );
|
|
||||||
|
|
||||||
$this->assertEquals( 2, $queue->get_queue_depth() );
|
|
||||||
|
|
||||||
$queue->clear();
|
|
||||||
|
|
||||||
$this->assertEquals( 0, $queue->get_queue_depth() );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test enqueue() with both span and trace types.
|
|
||||||
*/
|
|
||||||
public function test_enqueue_handles_trace_type(): void {
|
|
||||||
$queue = new \PRAutoBlogger_Opik_Span_Queue();
|
|
||||||
|
|
||||||
$queue->enqueue( array( 'id' => 'trace-1' ), 'trace' );
|
|
||||||
$queue->enqueue( array( 'id' => 'span-1' ), 'span' );
|
|
||||||
|
|
||||||
$batch = $queue->dequeue( 10 );
|
|
||||||
|
|
||||||
$this->assertCount( 2, $batch );
|
|
||||||
$this->assertEquals( 'trace', $batch[0]['type'] );
|
|
||||||
$this->assertEquals( 'span', $batch[1]['type'] );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unit tests for PRAutoBlogger_Opik_Trace_Context.
|
|
||||||
*
|
|
||||||
* @see includes/services/opik/class-opik-trace-context.php
|
|
||||||
*/
|
|
||||||
namespace PRAutoBlogger\Tests\Services;
|
|
||||||
|
|
||||||
use PRAutoBlogger\Tests\BaseTestCase;
|
|
||||||
use Brain\Monkey\Functions;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @group opik
|
|
||||||
* @group services
|
|
||||||
*/
|
|
||||||
class TestOpikTraceContext extends BaseTestCase {
|
|
||||||
|
|
||||||
protected function setUp(): void {
|
|
||||||
parent::setUp();
|
|
||||||
|
|
||||||
// Stub UUID generation.
|
|
||||||
Functions\when( 'wp_generate_uuid4' )->alias( function() {
|
|
||||||
static $counter = 0;
|
|
||||||
return 'uuid-' . ++$counter;
|
|
||||||
} );
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function tearDown(): void {
|
|
||||||
// Clean up singleton.
|
|
||||||
\PRAutoBlogger_Opik_Trace_Context::teardown();
|
|
||||||
parent::tearDown();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test current() returns singleton instance.
|
|
||||||
*/
|
|
||||||
public function test_current_returns_singleton(): void {
|
|
||||||
$ctx1 = \PRAutoBlogger_Opik_Trace_Context::current();
|
|
||||||
$ctx2 = \PRAutoBlogger_Opik_Trace_Context::current();
|
|
||||||
|
|
||||||
$this->assertSame( $ctx1, $ctx2 );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test init_trace() generates a UUID.
|
|
||||||
*/
|
|
||||||
public function test_init_trace_generates_uuid(): void {
|
|
||||||
$ctx = \PRAutoBlogger_Opik_Trace_Context::current();
|
|
||||||
$trace_id = $ctx->init_trace();
|
|
||||||
|
|
||||||
$this->assertNotEmpty( $trace_id );
|
|
||||||
$this->assertStringStartsWith( 'uuid-', $trace_id );
|
|
||||||
$this->assertEquals( $trace_id, $ctx->get_trace_id() );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test start_span() creates span and returns ID.
|
|
||||||
*/
|
|
||||||
public function test_start_span_creates_span(): void {
|
|
||||||
$ctx = \PRAutoBlogger_Opik_Trace_Context::current();
|
|
||||||
$ctx->init_trace();
|
|
||||||
|
|
||||||
$span_id = $ctx->start_span(
|
|
||||||
array(
|
|
||||||
'name' => 'draft_generation',
|
|
||||||
'type' => 'llm',
|
|
||||||
'model' => 'gpt-4',
|
|
||||||
'provider' => 'openrouter',
|
|
||||||
'input' => array( 'prompt_hash' => 'sha256-abc' ),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->assertNotEmpty( $span_id );
|
|
||||||
$this->assertStringStartsWith( 'uuid-', $span_id );
|
|
||||||
|
|
||||||
$span = $ctx->get_span( $span_id );
|
|
||||||
$this->assertIsArray( $span );
|
|
||||||
$this->assertEquals( 'draft_generation', $span['name'] );
|
|
||||||
$this->assertEquals( 'llm', $span['type'] );
|
|
||||||
$this->assertEquals( 'gpt-4', $span['model'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test end_span() records output and usage.
|
|
||||||
*/
|
|
||||||
public function test_end_span_records_data(): void {
|
|
||||||
$ctx = \PRAutoBlogger_Opik_Trace_Context::current();
|
|
||||||
$ctx->init_trace();
|
|
||||||
|
|
||||||
$span_id = $ctx->start_span(
|
|
||||||
array(
|
|
||||||
'name' => 'test_span',
|
|
||||||
'type' => 'llm',
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$ctx->end_span(
|
|
||||||
$span_id,
|
|
||||||
array(
|
|
||||||
'output' => array( 'response' => 'Hello world' ),
|
|
||||||
'usage' => array(
|
|
||||||
'prompt_tokens' => 50,
|
|
||||||
'completion_tokens' => 25,
|
|
||||||
'total_tokens' => 75,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$span = $ctx->get_span( $span_id );
|
|
||||||
$this->assertIsArray( $span['output'] );
|
|
||||||
$this->assertEquals( 'Hello world', $span['output']['response'] );
|
|
||||||
$this->assertEquals( 75, $span['usage']['total_tokens'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test get_spans() returns all active spans.
|
|
||||||
*/
|
|
||||||
public function test_get_spans_returns_all(): void {
|
|
||||||
$ctx = \PRAutoBlogger_Opik_Trace_Context::current();
|
|
||||||
$ctx->init_trace();
|
|
||||||
|
|
||||||
$span1 = $ctx->start_span( array( 'name' => 'span1', 'type' => 'llm' ) );
|
|
||||||
$span2 = $ctx->start_span( array( 'name' => 'span2', 'type' => 'llm' ) );
|
|
||||||
$span3 = $ctx->start_span( array( 'name' => 'span3', 'type' => 'llm' ) );
|
|
||||||
|
|
||||||
$all_spans = $ctx->get_spans();
|
|
||||||
|
|
||||||
$this->assertCount( 3, $all_spans );
|
|
||||||
$this->assertArrayHasKey( $span1, $all_spans );
|
|
||||||
$this->assertArrayHasKey( $span2, $all_spans );
|
|
||||||
$this->assertArrayHasKey( $span3, $all_spans );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test finalize_trace() returns complete trace object.
|
|
||||||
*/
|
|
||||||
public function test_finalize_trace_includes_all_data(): void {
|
|
||||||
Functions\when( 'get_option' )->alias(
|
|
||||||
function( $option, $default = false ) {
|
|
||||||
if ( 'prautoblogger_opik_project_name' === $option ) {
|
|
||||||
return 'prautoblogger';
|
|
||||||
}
|
|
||||||
return $default;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
$ctx = \PRAutoBlogger_Opik_Trace_Context::current();
|
|
||||||
$trace_id = $ctx->init_trace();
|
|
||||||
|
|
||||||
$span1 = $ctx->start_span( array( 'name' => 'step1', 'type' => 'llm' ) );
|
|
||||||
$ctx->end_span( $span1, array( 'output' => array( 'ok' => true ) ) );
|
|
||||||
|
|
||||||
$span2 = $ctx->start_span( array( 'name' => 'step2', 'type' => 'llm' ) );
|
|
||||||
$ctx->end_span( $span2, array( 'output' => array( 'ok' => true ) ) );
|
|
||||||
|
|
||||||
$trace = $ctx->finalize_trace();
|
|
||||||
|
|
||||||
$this->assertEquals( $trace_id, $trace['id'] );
|
|
||||||
$this->assertEquals( 'article_generation', $trace['name'] );
|
|
||||||
$this->assertEquals( 'prautoblogger', $trace['project_name'] );
|
|
||||||
$this->assertArrayHasKey( 'start_time', $trace );
|
|
||||||
$this->assertArrayHasKey( 'end_time', $trace );
|
|
||||||
$this->assertCount( 2, $trace['spans'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test teardown() clears the singleton.
|
|
||||||
*/
|
|
||||||
public function test_teardown_clears_singleton(): void {
|
|
||||||
$ctx1 = \PRAutoBlogger_Opik_Trace_Context::current();
|
|
||||||
$ctx1->init_trace();
|
|
||||||
|
|
||||||
\PRAutoBlogger_Opik_Trace_Context::teardown();
|
|
||||||
|
|
||||||
$ctx2 = \PRAutoBlogger_Opik_Trace_Context::current();
|
|
||||||
|
|
||||||
// Should be a new instance.
|
|
||||||
$this->assertNotSame( $ctx1, $ctx2 );
|
|
||||||
$this->assertEmpty( $ctx2->get_trace_id() );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test warning logged for ending unknown span.
|
|
||||||
*/
|
|
||||||
public function test_warning_for_unknown_span(): void {
|
|
||||||
$ctx = \PRAutoBlogger_Opik_Trace_Context::current();
|
|
||||||
$ctx->init_trace();
|
|
||||||
|
|
||||||
// Try to end a span that doesn't exist.
|
|
||||||
$ctx->end_span( 'unknown-uuid-12345' );
|
|
||||||
|
|
||||||
// If we got here without an exception, the warning was logged.
|
|
||||||
$this->assertTrue( true );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test span input is stored as-is (caller responsible for redaction).
|
|
||||||
*/
|
|
||||||
public function test_span_input_stored_as_is(): void {
|
|
||||||
$ctx = \PRAutoBlogger_Opik_Trace_Context::current();
|
|
||||||
$ctx->init_trace();
|
|
||||||
|
|
||||||
$input_data = array(
|
|
||||||
'prompt_hash' => 'sha256-abc123',
|
|
||||||
'model' => 'gpt-4',
|
|
||||||
'version' => '1.0',
|
|
||||||
);
|
|
||||||
|
|
||||||
$span_id = $ctx->start_span(
|
|
||||||
array(
|
|
||||||
'name' => 'test',
|
|
||||||
'type' => 'llm',
|
|
||||||
'input' => $input_data,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$span = $ctx->get_span( $span_id );
|
|
||||||
$this->assertEquals( $input_data, $span['input'] );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user