@relvehq/agent-ready 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,894 @@
1
+ <?php
2
+ /**
3
+ * Refiner: rewrite posts with Relve's prompt and the site's own OpenAI key.
4
+ *
5
+ * The prompt never leaves Relve's server, so the model call happens there:
6
+ * this class sends the rendered post (a small, complete HTML document) and
7
+ * its permalink to the Relve refine endpoint (contract:
8
+ * changes/agent-ready-byok-refine/contract.md) with the site's OpenAI key in
9
+ * the `X-OpenAI-Key` header, and stores the returned Markdown as the post's
10
+ * refined file, the same file the router serves first ("stored").
11
+ *
12
+ * save / publish -> transition_post_status -> one WP-Cron single event per
13
+ * post, 30 s later (never during the save request)
14
+ * Rewrite all now -> a queue of post IDs in an option, 5 per cron run,
15
+ * the batch event reschedules itself until done
16
+ * unpublish / delete -> the post's refined file is deleted
17
+ *
18
+ * Storage: the key lives in the settings option only. Per post, post meta
19
+ * holds the fingerprint (`_relve_ar_fingerprint`), the time of the last
20
+ * rewrite (`_relve_ar_refined_at`) and the stored file's path
21
+ * (`_relve_ar_refined_path`). Run state (last run, last error code, pause)
22
+ * lives in `relve_agent_ready_refine`; the batch queue in
23
+ * `relve_agent_ready_refine_queue`. Error codes are stored, never messages,
24
+ * and never the key.
25
+ *
26
+ * The key is only ever sent to an https endpoint (http is allowed for
27
+ * localhost only, for testing), with redirects off so it cannot be forwarded.
28
+ *
29
+ * @package Relve_Agent_Ready
30
+ */
31
+
32
+ if ( ! defined( 'ABSPATH' ) ) {
33
+ exit;
34
+ }
35
+
36
+ /**
37
+ * Bring-your-own-key rewrites through the Relve API.
38
+ */
39
+ class Relve_AR_Refiner {
40
+
41
+ const DEFAULT_ENDPOINT = 'https://relvehq.com/api/agent-ready/v1/refine';
42
+
43
+ const HOOK_POST = 'relve_ar_refine_post';
44
+ const HOOK_BATCH = 'relve_ar_refine_batch';
45
+
46
+ const STATE = 'relve_agent_ready_refine';
47
+ const QUEUE = 'relve_agent_ready_refine_queue';
48
+ const LOCK = 'relve_ar_refine_lock';
49
+
50
+ const META_FINGERPRINT = '_relve_ar_fingerprint';
51
+ const META_REFINED_AT = '_relve_ar_refined_at';
52
+ const META_PATH = '_relve_ar_refined_path';
53
+
54
+ /** Posts per batch run. */
55
+ const BATCH_SIZE = 5;
56
+
57
+ /** Seconds between a save and its rewrite. */
58
+ const SAVE_DELAY = 30;
59
+
60
+ /** Seconds between batch runs. */
61
+ const BATCH_GAP = 5;
62
+
63
+ /** HTTP timeout in seconds (one model call: 5-30 s). */
64
+ const TIMEOUT = 90;
65
+
66
+ /** The contract's html limit in bytes. */
67
+ const MAX_HTML_BYTES = 2000000;
68
+
69
+ /** Errors that stop the batch until the key is fixed. */
70
+ const PAUSE_CODES = array( 'missing_key', 'openai_auth', 'openai_quota' );
71
+
72
+ /** Errors that make the batch wait and try the same post again. */
73
+ const WAIT_CODES = array( 'rate_limited', 'openai_rate_limited' );
74
+
75
+ /**
76
+ * Register hooks (front end, admin and cron).
77
+ *
78
+ * @return void
79
+ */
80
+ public static function init() {
81
+ add_action( 'transition_post_status', array( __CLASS__, 'on_transition' ), 10, 3 );
82
+ add_action( 'before_delete_post', array( __CLASS__, 'forget_post' ), 10, 1 );
83
+ add_action( self::HOOK_POST, array( __CLASS__, 'run_post' ), 10, 1 );
84
+ add_action( self::HOOK_BATCH, array( __CLASS__, 'run_batch' ) );
85
+ }
86
+
87
+ // ── endpoint ──────────────────────────────────────────────────────────
88
+
89
+ /**
90
+ * Endpoint URL: RELVE_AR_REFINE_ENDPOINT, filterable.
91
+ *
92
+ * @return string
93
+ */
94
+ public static function endpoint() {
95
+ $url = defined( 'RELVE_AR_REFINE_ENDPOINT' ) ? (string) RELVE_AR_REFINE_ENDPOINT : self::DEFAULT_ENDPOINT;
96
+ return (string) apply_filters( 'relve_ar_refine_endpoint', $url );
97
+ }
98
+
99
+ /**
100
+ * May the key be sent to this URL? https only; http for localhost only.
101
+ * No credentials in the URL.
102
+ *
103
+ * @param string $url Endpoint.
104
+ * @return bool
105
+ */
106
+ public static function endpoint_allowed( $url ) {
107
+ $p = wp_parse_url( (string) $url );
108
+ if ( ! is_array( $p ) || empty( $p['scheme'] ) || empty( $p['host'] ) || isset( $p['user'] ) || isset( $p['pass'] ) ) {
109
+ return false;
110
+ }
111
+ $scheme = strtolower( $p['scheme'] );
112
+ $host = strtolower( trim( $p['host'], '[]' ) );
113
+ if ( 'https' === $scheme ) {
114
+ return true;
115
+ }
116
+ return 'http' === $scheme && in_array( $host, array( 'localhost', '127.0.0.1', '::1' ), true );
117
+ }
118
+
119
+ // ── paths ─────────────────────────────────────────────────────────────
120
+
121
+ /**
122
+ * Stored-file path for a permalink, the one the router looks up first.
123
+ * Null for plain permalinks (?p=12), which have no path of their own.
124
+ *
125
+ * @param string $url Permalink.
126
+ * @return string|null
127
+ */
128
+ public static function rel_for_url( $url ) {
129
+ $p = wp_parse_url( (string) $url );
130
+ if ( ! is_array( $p ) || empty( $p['host'] ) || ! empty( $p['query'] ) ) {
131
+ return null;
132
+ }
133
+ $path = ( isset( $p['path'] ) && '' !== $p['path'] ) ? $p['path'] : '/';
134
+ return Relve_AR_Store::rel_for_path( $path );
135
+ }
136
+
137
+ // ── request ───────────────────────────────────────────────────────────
138
+
139
+ /**
140
+ * A minimal, complete HTML document for the post: title, description,
141
+ * canonical, JSON-LD, and the rendered content in <main><article>.
142
+ *
143
+ * @param WP_Post $post Post.
144
+ * @param string $url Permalink.
145
+ * @return string
146
+ */
147
+ public static function build_html( $post, $url ) {
148
+ $title = html_entity_decode( wp_strip_all_tags( get_the_title( $post ) ), ENT_QUOTES, 'UTF-8' );
149
+ $desc = Relve_AR_Markdown_Builder::description_for( $post );
150
+ $content = Relve_AR_Markdown_Builder::render_content( $post );
151
+ $schema = self::schema_for( $post, $url, $title, $desc );
152
+ $lang = (string) get_bloginfo( 'language' );
153
+
154
+ $image = '';
155
+ $thumb_id = function_exists( 'get_post_thumbnail_id' ) ? get_post_thumbnail_id( $post ) : 0;
156
+ if ( $thumb_id ) {
157
+ $src = wp_get_attachment_image_url( $thumb_id, 'full' );
158
+ if ( $src ) {
159
+ $alt = (string) get_post_meta( $thumb_id, '_wp_attachment_image_alt', true );
160
+ $image = '<img src="' . self::h( $src ) . '" alt="' . self::h( '' === $alt ? $title : $alt ) . "\">\n";
161
+ }
162
+ }
163
+
164
+ $head = '<meta charset="utf-8">' . "\n"
165
+ . '<title>' . self::h( $title ) . "</title>\n";
166
+ if ( '' !== $desc ) {
167
+ $head .= '<meta name="description" content="' . self::h( $desc ) . "\">\n";
168
+ }
169
+ $head .= '<link rel="canonical" href="' . self::h( $url ) . "\">\n";
170
+ if ( null !== $schema ) {
171
+ $head .= '<script type="application/ld+json">' . $schema . "</script>\n";
172
+ }
173
+
174
+ return "<!doctype html>\n"
175
+ . '<html' . ( '' !== $lang ? ' lang="' . self::h( $lang ) . '"' : '' ) . ">\n"
176
+ . "<head>\n" . $head . "</head>\n"
177
+ . "<body>\n<main>\n<article>\n"
178
+ . '<h1>' . self::h( '' === $title ? 'Untitled' : $title ) . "</h1>\n"
179
+ . $image
180
+ . trim( $content ) . "\n"
181
+ . "</article>\n</main>\n</body>\n</html>\n";
182
+ }
183
+
184
+ /**
185
+ * JSON-LD: Yoast's graph when Yoast provides one, else a simple Article.
186
+ *
187
+ * @param WP_Post $post Post.
188
+ * @param string $url Permalink.
189
+ * @param string $title Title.
190
+ * @param string $desc Description.
191
+ * @return string|null JSON, safe inside <script>.
192
+ */
193
+ public static function schema_for( $post, $url, $title, $desc ) {
194
+ if ( function_exists( 'YoastSEO' ) ) {
195
+ try {
196
+ $meta = YoastSEO()->meta->for_post( $post->ID );
197
+ $graph = $meta ? $meta->schema : null;
198
+ if ( is_array( $graph ) && $graph ) {
199
+ $json = self::json( $graph );
200
+ if ( null !== $json ) {
201
+ return $json;
202
+ }
203
+ }
204
+ } catch ( Throwable $e ) {
205
+ // Fall back to the simple Article below.
206
+ unset( $e );
207
+ }
208
+ }
209
+ $article = array(
210
+ '@context' => 'https://schema.org',
211
+ '@type' => 'Article',
212
+ 'headline' => $title,
213
+ 'url' => $url,
214
+ 'datePublished' => (string) get_post_time( 'c', true, $post ),
215
+ 'dateModified' => (string) get_post_modified_time( 'c', true, $post ),
216
+ );
217
+ $author = html_entity_decode( (string) get_the_author_meta( 'display_name', $post->post_author ), ENT_QUOTES, 'UTF-8' );
218
+ if ( '' !== $author ) {
219
+ $article['author'] = array(
220
+ '@type' => 'Person',
221
+ 'name' => $author,
222
+ );
223
+ }
224
+ if ( '' !== $desc ) {
225
+ $article['description'] = $desc;
226
+ }
227
+ return self::json( $article );
228
+ }
229
+
230
+ /**
231
+ * Request body for a post (contract fields).
232
+ *
233
+ * @param WP_Post $post Post.
234
+ * @param string $url Permalink.
235
+ * @param string $model Model.
236
+ * @param string $rel Stored-file path.
237
+ * @param Relve_AR_Store $store Store.
238
+ * @return array
239
+ */
240
+ public static function build_payload( $post, $url, $model, $rel, $store ) {
241
+ $payload = array(
242
+ 'url' => $url,
243
+ 'html' => self::build_html( $post, $url ),
244
+ 'model' => $model,
245
+ );
246
+ $site = self::cut( html_entity_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES, 'UTF-8' ), 120 );
247
+ if ( '' !== $site ) {
248
+ $payload['siteName'] = $site;
249
+ }
250
+ // Only claim a fingerprint when its file is still there: after a
251
+ // "Clear" or a zip upload the server must write the page again.
252
+ $fp = (string) get_post_meta( $post->ID, self::META_FINGERPRINT, true );
253
+ if ( '' !== $fp && (string) get_post_meta( $post->ID, self::META_PATH, true ) === $rel && $store->exists( $rel ) ) {
254
+ $payload['previousFingerprint'] = $fp;
255
+ }
256
+ return $payload;
257
+ }
258
+
259
+ /**
260
+ * wp_remote_post() arguments. The key goes in a header only.
261
+ *
262
+ * @param string $body JSON body.
263
+ * @param string $key OpenAI key.
264
+ * @return array
265
+ */
266
+ public static function request_args( $body, $key ) {
267
+ $ua = 'relve-agent-ready-wp/' . ( defined( 'RELVE_AR_VERSION' ) ? RELVE_AR_VERSION : '0' );
268
+ return array(
269
+ 'method' => 'POST',
270
+ 'timeout' => self::TIMEOUT,
271
+ 'redirection' => 0, // A redirect must never carry the key elsewhere.
272
+ 'sslverify' => true,
273
+ 'user-agent' => $ua,
274
+ 'headers' => array(
275
+ 'Content-Type' => 'application/json',
276
+ 'Accept' => 'application/json',
277
+ 'X-OpenAI-Key' => $key,
278
+ 'User-Agent' => $ua,
279
+ ),
280
+ 'body' => $body,
281
+ 'data_format' => 'body',
282
+ );
283
+ }
284
+
285
+ // ── response ──────────────────────────────────────────────────────────
286
+
287
+ /**
288
+ * Map an HTTP response to { status: ok|unchanged|error, code, markdown,
289
+ * fingerprint, retry_after }. Only fixed codes come out; server messages
290
+ * are dropped.
291
+ *
292
+ * @param array|WP_Error $response wp_remote_post() result.
293
+ * @return array
294
+ */
295
+ public static function parse_response( $response ) {
296
+ if ( is_wp_error( $response ) ) {
297
+ return self::result( 'error', 'network' );
298
+ }
299
+ $http = (int) wp_remote_retrieve_response_code( $response );
300
+ $body = json_decode( (string) wp_remote_retrieve_body( $response ), true );
301
+ if ( ! is_array( $body ) || ! isset( $body['status'] ) || ! is_string( $body['status'] ) ) {
302
+ return self::result( 'error', 'bad_response' );
303
+ }
304
+ $fingerprint = self::clean_fingerprint( isset( $body['fingerprint'] ) ? $body['fingerprint'] : '' );
305
+ switch ( $body['status'] ) {
306
+ case 'ok':
307
+ if ( 200 !== $http || ! isset( $body['markdown'] ) || ! is_string( $body['markdown'] ) || '' === trim( $body['markdown'] ) ) {
308
+ return self::result( 'error', 'bad_response' );
309
+ }
310
+ $r = self::result( 'ok' );
311
+ $r['markdown'] = $body['markdown'];
312
+ $r['fingerprint'] = $fingerprint;
313
+ return $r;
314
+ case 'unchanged':
315
+ if ( 200 !== $http ) {
316
+ return self::result( 'error', 'bad_response' );
317
+ }
318
+ $r = self::result( 'unchanged' );
319
+ $r['fingerprint'] = $fingerprint;
320
+ return $r;
321
+ case 'rejected':
322
+ return self::result( 'error', 'rejected' );
323
+ case 'error':
324
+ $code = ( isset( $body['error'] ) && is_string( $body['error'] ) && preg_match( '/^[a-z_]{1,40}$/', $body['error'] ) ) ? $body['error'] : 'upstream';
325
+ $r = self::result( 'error', $code );
326
+ if ( isset( $body['retryAfter'] ) && is_numeric( $body['retryAfter'] ) ) {
327
+ $r['retry_after'] = (int) $body['retryAfter'];
328
+ } else {
329
+ $header = wp_remote_retrieve_header( $response, 'retry-after' );
330
+ if ( is_numeric( $header ) ) {
331
+ $r['retry_after'] = (int) $header;
332
+ }
333
+ }
334
+ return $r;
335
+ }
336
+ return self::result( 'error', 'bad_response' );
337
+ }
338
+
339
+ // ── running ───────────────────────────────────────────────────────────
340
+
341
+ /**
342
+ * Rewrite one post now (cron context). Records the outcome.
343
+ *
344
+ * @param int $post_id Post ID.
345
+ * @return array Result (see parse_response), status may also be "skipped".
346
+ */
347
+ public static function refine_post( $post_id ) {
348
+ $post_id = (int) $post_id;
349
+ $s = Relve_AR_Settings::get();
350
+ if ( '' === $s['openai_key'] ) {
351
+ return self::record_error( 'missing_key', $post_id );
352
+ }
353
+ $post = get_post( $post_id );
354
+ if ( ! Relve_AR_Router::servable_post( $post, $s['post_types'] ) ) {
355
+ self::forget_post( $post_id );
356
+ return self::result( 'skipped' );
357
+ }
358
+ $url = get_permalink( $post );
359
+ $rel = $url ? self::rel_for_url( $url ) : null;
360
+ if ( null === $rel ) {
361
+ return self::record_error( 'no_path', $post_id );
362
+ }
363
+ $endpoint = self::endpoint();
364
+ if ( ! self::endpoint_allowed( $endpoint ) ) {
365
+ return self::record_error( 'bad_endpoint', $post_id );
366
+ }
367
+
368
+ $store = new Relve_AR_Store();
369
+ $payload = self::build_payload( $post, $url, $s['refine_model'], $rel, $store );
370
+ if ( strlen( $payload['html'] ) > self::MAX_HTML_BYTES ) {
371
+ return self::record_error( 'too_large', $post_id );
372
+ }
373
+ $body = self::json( $payload );
374
+ if ( null === $body ) {
375
+ return self::record_error( 'bad_request', $post_id );
376
+ }
377
+
378
+ $r = self::parse_response( wp_remote_post( $endpoint, self::request_args( $body, $s['openai_key'] ) ) );
379
+ return self::apply( $r, $post_id, $rel, $store );
380
+ }
381
+
382
+ /**
383
+ * Act on a parsed response.
384
+ *
385
+ * @param array $r Parsed response.
386
+ * @param int $post_id Post ID.
387
+ * @param string $rel Stored-file path.
388
+ * @param Relve_AR_Store $store Store.
389
+ * @return array
390
+ */
391
+ public static function apply( $r, $post_id, $rel, $store ) {
392
+ if ( 'unchanged' === $r['status'] ) {
393
+ self::touch_state( array() );
394
+ return $r;
395
+ }
396
+ if ( 'ok' !== $r['status'] ) {
397
+ $out = self::record_error( $r['code'], $post_id );
398
+ $out['retry_after'] = $r['retry_after'];
399
+ return $out;
400
+ }
401
+ if ( ! preg_match( '//u', $r['markdown'] ) ) {
402
+ return self::record_error( 'bad_response', $post_id );
403
+ }
404
+ $store->ensure();
405
+ if ( ! $store->put( $rel, $r['markdown'] ) ) {
406
+ return self::record_error( 'store_failed', $post_id );
407
+ }
408
+ $old = (string) get_post_meta( $post_id, self::META_PATH, true );
409
+ if ( '' !== $old && $old !== $rel ) {
410
+ $store->delete( $old ); // The permalink changed.
411
+ }
412
+ if ( '' !== $r['fingerprint'] ) {
413
+ update_post_meta( $post_id, self::META_FINGERPRINT, $r['fingerprint'] );
414
+ } else {
415
+ delete_post_meta( $post_id, self::META_FINGERPRINT );
416
+ }
417
+ update_post_meta( $post_id, self::META_REFINED_AT, time() );
418
+ update_post_meta( $post_id, self::META_PATH, $rel );
419
+ self::touch_state( array() );
420
+ unset( $r['markdown'] );
421
+ return $r;
422
+ }
423
+
424
+ /**
425
+ * Cron: one post after a save.
426
+ *
427
+ * @param int $post_id Post ID.
428
+ * @return void
429
+ */
430
+ public static function run_post( $post_id ) {
431
+ self::extend_time();
432
+ self::refine_post( (int) $post_id );
433
+ }
434
+
435
+ /**
436
+ * Cron: the next BATCH_SIZE posts of the "Rewrite all now" queue.
437
+ *
438
+ * @return array { processed: int, remaining: int, stopped: ''|paused|waiting|locked }
439
+ */
440
+ public static function run_batch() {
441
+ $out = array(
442
+ 'processed' => 0,
443
+ 'remaining' => 0,
444
+ 'stopped' => '',
445
+ );
446
+ $state = self::state();
447
+ $q = self::queue();
448
+ $out['remaining'] = count( $q['ids'] );
449
+ if ( '' !== $state['paused'] ) {
450
+ $out['stopped'] = 'paused';
451
+ return $out;
452
+ }
453
+ if ( get_transient( self::LOCK ) ) {
454
+ $out['stopped'] = 'locked';
455
+ return $out;
456
+ }
457
+ set_transient( self::LOCK, 1, 900 );
458
+ self::extend_time();
459
+ try {
460
+ while ( $q['ids'] && $out['processed'] < self::BATCH_SIZE ) {
461
+ $id = (int) array_shift( $q['ids'] );
462
+ $r = self::refine_post( $id );
463
+ if ( 'error' === $r['status'] && in_array( $r['code'], self::PAUSE_CODES, true ) ) {
464
+ array_unshift( $q['ids'], $id );
465
+ self::save_queue( $q );
466
+ self::pause( $r['code'] );
467
+ $out['stopped'] = 'paused';
468
+ break;
469
+ }
470
+ if ( 'error' === $r['status'] && in_array( $r['code'], self::WAIT_CODES, true ) ) {
471
+ array_unshift( $q['ids'], $id );
472
+ self::save_queue( $q );
473
+ $wait = $r['retry_after'] > 0 ? $r['retry_after'] : 60;
474
+ self::schedule_batch( time() + max( 30, min( 3600, $wait ) ) );
475
+ $out['stopped'] = 'waiting';
476
+ break;
477
+ }
478
+ ++$q['done'];
479
+ ++$out['processed'];
480
+ self::save_queue( $q );
481
+ }
482
+ if ( '' === $out['stopped'] ) {
483
+ if ( $q['ids'] ) {
484
+ self::schedule_batch( time() + self::BATCH_GAP );
485
+ } else {
486
+ delete_option( self::QUEUE );
487
+ self::touch_state(
488
+ array(
489
+ 'last_batch' => array(
490
+ 'done' => $q['done'],
491
+ 'total' => $q['total'],
492
+ 'at' => time(),
493
+ ),
494
+ )
495
+ );
496
+ }
497
+ }
498
+ } finally {
499
+ delete_transient( self::LOCK );
500
+ }
501
+ $out['remaining'] = count( $q['ids'] );
502
+ return $out;
503
+ }
504
+
505
+ /**
506
+ * "Rewrite all now": queue every published post of the enabled types.
507
+ *
508
+ * @return int Posts queued (0 without a key).
509
+ */
510
+ public static function start_all() {
511
+ $s = Relve_AR_Settings::get();
512
+ if ( '' === $s['openai_key'] || ! $s['post_types'] ) {
513
+ return 0;
514
+ }
515
+ $ids = get_posts(
516
+ array(
517
+ 'post_type' => $s['post_types'],
518
+ 'post_status' => 'publish',
519
+ 'has_password' => false,
520
+ 'numberposts' => -1,
521
+ 'fields' => 'ids',
522
+ 'orderby' => 'ID',
523
+ 'order' => 'ASC',
524
+ 'suppress_filters' => true,
525
+ 'no_found_rows' => true,
526
+ )
527
+ );
528
+ $ids = array_values( array_unique( array_map( 'intval', (array) $ids ) ) );
529
+ if ( ! $ids ) {
530
+ delete_option( self::QUEUE );
531
+ return 0;
532
+ }
533
+ self::save_queue(
534
+ array(
535
+ 'ids' => $ids,
536
+ 'total' => count( $ids ),
537
+ 'done' => 0,
538
+ )
539
+ );
540
+ self::touch_state(
541
+ array(
542
+ 'paused' => '',
543
+ 'last_error' => '',
544
+ )
545
+ );
546
+ wp_clear_scheduled_hook( self::HOOK_BATCH );
547
+ self::schedule_batch( time() );
548
+ return count( $ids );
549
+ }
550
+
551
+ // ── post lifecycle ────────────────────────────────────────────────────
552
+
553
+ /**
554
+ * transition_post_status: schedule a rewrite for published posts; forget
555
+ * the file when a post leaves "publish".
556
+ *
557
+ * @param string $new_status New status.
558
+ * @param string $old_status Old status.
559
+ * @param WP_Post $post Post.
560
+ * @return void
561
+ */
562
+ public static function on_transition( $new_status, $old_status, $post ) {
563
+ if ( ! ( $post instanceof WP_Post ) || self::is_revision_or_autosave( $post ) ) {
564
+ return;
565
+ }
566
+ if ( 'publish' !== $new_status ) {
567
+ if ( 'publish' === $old_status ) {
568
+ self::forget_post( $post->ID );
569
+ }
570
+ return;
571
+ }
572
+ self::maybe_schedule( $post );
573
+ }
574
+
575
+ /**
576
+ * Schedule one rewrite for a published post (deduplicated).
577
+ *
578
+ * @param WP_Post $post Post.
579
+ * @return bool Whether an event was scheduled.
580
+ */
581
+ public static function maybe_schedule( $post ) {
582
+ if ( ! ( $post instanceof WP_Post ) || self::is_revision_or_autosave( $post ) ) {
583
+ return false;
584
+ }
585
+ $s = Relve_AR_Settings::get();
586
+ if ( ! $s['enabled'] || ! $s['refine_on_save'] || '' === $s['openai_key'] ) {
587
+ return false;
588
+ }
589
+ if ( ! Relve_AR_Router::servable_post( $post, $s['post_types'] ) ) {
590
+ self::forget_post( $post->ID ); // Now password-protected or no longer public.
591
+ return false;
592
+ }
593
+ $args = array( (int) $post->ID );
594
+ if ( false !== wp_next_scheduled( self::HOOK_POST, $args ) ) {
595
+ return false;
596
+ }
597
+ return true === wp_schedule_single_event( time() + self::SAVE_DELAY, self::HOOK_POST, $args );
598
+ }
599
+
600
+ /**
601
+ * Revision, autosave, or an autosave request.
602
+ *
603
+ * @param WP_Post $post Post.
604
+ * @return bool
605
+ */
606
+ private static function is_revision_or_autosave( $post ) {
607
+ if ( 'revision' === $post->post_type || ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) ) {
608
+ return true;
609
+ }
610
+ return (bool) wp_is_post_revision( $post ) || (bool) wp_is_post_autosave( $post );
611
+ }
612
+
613
+ /**
614
+ * Delete a post's refined file and its meta; drop its pending event.
615
+ *
616
+ * @param int $post_id Post ID.
617
+ * @return void
618
+ */
619
+ public static function forget_post( $post_id ) {
620
+ $post_id = (int) $post_id;
621
+ $rel = (string) get_post_meta( $post_id, self::META_PATH, true );
622
+ if ( '' !== $rel ) {
623
+ $store = new Relve_AR_Store();
624
+ $store->delete( $rel );
625
+ }
626
+ delete_post_meta( $post_id, self::META_FINGERPRINT );
627
+ delete_post_meta( $post_id, self::META_REFINED_AT );
628
+ delete_post_meta( $post_id, self::META_PATH );
629
+ wp_clear_scheduled_hook( self::HOOK_POST, array( $post_id ) );
630
+ }
631
+
632
+ /**
633
+ * Drop every post's refine meta (after "Clear refined files").
634
+ *
635
+ * @return void
636
+ */
637
+ public static function forget_all_meta() {
638
+ delete_post_meta_by_key( self::META_FINGERPRINT );
639
+ delete_post_meta_by_key( self::META_REFINED_AT );
640
+ delete_post_meta_by_key( self::META_PATH );
641
+ }
642
+
643
+ /**
644
+ * The key was replaced or removed: lift a pause, resume a waiting queue.
645
+ *
646
+ * @param bool $has_key Whether a key is now saved.
647
+ * @return void
648
+ */
649
+ public static function key_changed( $has_key ) {
650
+ self::touch_state( array( 'paused' => '' ) );
651
+ $q = self::queue();
652
+ if ( $has_key && $q['ids'] ) {
653
+ self::schedule_batch( time() + self::BATCH_GAP );
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Deactivation: no scheduled work left behind.
659
+ *
660
+ * @return void
661
+ */
662
+ public static function unschedule_all() {
663
+ wp_unschedule_hook( self::HOOK_POST );
664
+ wp_unschedule_hook( self::HOOK_BATCH );
665
+ delete_option( self::QUEUE );
666
+ delete_transient( self::LOCK );
667
+ }
668
+
669
+ // ── state ─────────────────────────────────────────────────────────────
670
+
671
+ /**
672
+ * Run state.
673
+ *
674
+ * @return array { last_run, last_error, last_error_at, last_error_post, paused, last_batch }
675
+ */
676
+ public static function state() {
677
+ $stored = get_option( self::STATE, array() );
678
+ $s = array_merge(
679
+ array(
680
+ 'last_run' => 0,
681
+ 'last_error' => '',
682
+ 'last_error_at' => 0,
683
+ 'last_error_post' => 0,
684
+ 'paused' => '',
685
+ 'last_batch' => null,
686
+ ),
687
+ is_array( $stored ) ? $stored : array()
688
+ );
689
+ $s['paused'] = is_string( $s['paused'] ) ? $s['paused'] : '';
690
+ $s['last_error'] = is_string( $s['last_error'] ) ? $s['last_error'] : '';
691
+ return $s;
692
+ }
693
+
694
+ /**
695
+ * Batch queue.
696
+ *
697
+ * @return array { ids: int[], total: int, done: int }
698
+ */
699
+ public static function queue() {
700
+ $q = get_option( self::QUEUE, array() );
701
+ $q = is_array( $q ) ? $q : array();
702
+ return array(
703
+ 'ids' => isset( $q['ids'] ) && is_array( $q['ids'] ) ? array_values( array_map( 'intval', $q['ids'] ) ) : array(),
704
+ 'total' => isset( $q['total'] ) ? (int) $q['total'] : 0,
705
+ 'done' => isset( $q['done'] ) ? (int) $q['done'] : 0,
706
+ );
707
+ }
708
+
709
+ /**
710
+ * Number of posts with a rewritten file.
711
+ *
712
+ * @return int
713
+ */
714
+ public static function count_rewritten() {
715
+ $ids = get_posts(
716
+ array(
717
+ 'post_type' => 'any',
718
+ 'post_status' => 'any',
719
+ 'meta_key' => self::META_REFINED_AT, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- admin page only.
720
+ 'numberposts' => -1,
721
+ 'fields' => 'ids',
722
+ 'suppress_filters' => true,
723
+ 'no_found_rows' => true,
724
+ )
725
+ );
726
+ return is_array( $ids ) ? count( $ids ) : 0;
727
+ }
728
+
729
+ /**
730
+ * Plain-English label for an error code (admin).
731
+ *
732
+ * @param string $code Code.
733
+ * @return string
734
+ */
735
+ public static function error_label( $code ) {
736
+ $labels = array(
737
+ 'missing_key' => __( 'No OpenAI key is saved.', 'relve-agent-ready' ),
738
+ 'openai_auth' => __( 'OpenAI rejected the key. Replace it below.', 'relve-agent-ready' ),
739
+ 'openai_quota' => __( 'The OpenAI account has no quota or billing left.', 'relve-agent-ready' ),
740
+ 'openai_rate_limited' => __( 'OpenAI rate-limited the key; it will be retried.', 'relve-agent-ready' ),
741
+ 'rate_limited' => __( 'Relve rate-limited this site; it will be retried.', 'relve-agent-ready' ),
742
+ 'rejected' => __( 'The rewrite did not pass Relve\'s check; the WordPress Markdown is served.', 'relve-agent-ready' ),
743
+ 'too_little_text' => __( 'The page has too little text to rewrite.', 'relve-agent-ready' ),
744
+ 'too_large' => __( 'The page is too large to rewrite.', 'relve-agent-ready' ),
745
+ 'bad_request' => __( 'The request was refused as invalid.', 'relve-agent-ready' ),
746
+ 'upstream' => __( 'OpenAI returned an error.', 'relve-agent-ready' ),
747
+ 'timeout' => __( 'The model took too long.', 'relve-agent-ready' ),
748
+ 'network' => __( 'Relve could not be reached.', 'relve-agent-ready' ),
749
+ 'bad_response' => __( 'Relve sent an unexpected answer.', 'relve-agent-ready' ),
750
+ 'bad_endpoint' => __( 'The endpoint is not https; the key was not sent.', 'relve-agent-ready' ),
751
+ 'no_path' => __( 'Plain permalinks (?p=) have no path to store a file under.', 'relve-agent-ready' ),
752
+ 'store_failed' => __( 'The file could not be written. Check that wp-content/uploads is writable.', 'relve-agent-ready' ),
753
+ );
754
+ return isset( $labels[ $code ] ) ? $labels[ $code ] : __( 'Unexpected error.', 'relve-agent-ready' );
755
+ }
756
+
757
+ // ── internals ─────────────────────────────────────────────────────────
758
+
759
+ /**
760
+ * A result array.
761
+ *
762
+ * @param string $status ok|unchanged|error|skipped.
763
+ * @param string $code Error code.
764
+ * @return array
765
+ */
766
+ private static function result( $status, $code = '' ) {
767
+ return array(
768
+ 'status' => $status,
769
+ 'code' => $code,
770
+ 'markdown' => null,
771
+ 'fingerprint' => '',
772
+ 'retry_after' => 0,
773
+ );
774
+ }
775
+
776
+ /**
777
+ * Record an error code (never a message, never the key).
778
+ *
779
+ * @param string $code Code.
780
+ * @param int $post_id Post ID.
781
+ * @return array
782
+ */
783
+ private static function record_error( $code, $post_id ) {
784
+ $code = preg_match( '/^[a-z_]{1,40}$/', (string) $code ) ? (string) $code : 'upstream';
785
+ self::touch_state(
786
+ array(
787
+ 'last_error' => $code,
788
+ 'last_error_at' => time(),
789
+ 'last_error_post' => (int) $post_id,
790
+ )
791
+ );
792
+ return self::result( 'error', $code );
793
+ }
794
+
795
+ /**
796
+ * Pause the batch.
797
+ *
798
+ * @param string $code Code.
799
+ * @return void
800
+ */
801
+ private static function pause( $code ) {
802
+ self::touch_state( array( 'paused' => $code ) );
803
+ }
804
+
805
+ /**
806
+ * Merge into the state and stamp last_run.
807
+ *
808
+ * @param array $changes Changes.
809
+ * @return void
810
+ */
811
+ private static function touch_state( $changes ) {
812
+ $s = array_merge( self::state(), $changes );
813
+ $s['last_run'] = time();
814
+ update_option( self::STATE, $s, false );
815
+ }
816
+
817
+ /**
818
+ * Save the queue (not autoloaded).
819
+ *
820
+ * @param array $q Queue.
821
+ * @return void
822
+ */
823
+ private static function save_queue( $q ) {
824
+ update_option( self::QUEUE, $q, false );
825
+ }
826
+
827
+ /**
828
+ * Schedule the batch event unless one is pending.
829
+ *
830
+ * @param int $when Timestamp.
831
+ * @return void
832
+ */
833
+ private static function schedule_batch( $when ) {
834
+ if ( false === wp_next_scheduled( self::HOOK_BATCH ) ) {
835
+ wp_schedule_single_event( $when, self::HOOK_BATCH );
836
+ }
837
+ }
838
+
839
+ /**
840
+ * A model call can take 30 s; a batch of five more. Lift the time limit
841
+ * where the host allows it (cron runs only).
842
+ *
843
+ * @return void
844
+ */
845
+ private static function extend_time() {
846
+ $disabled = (string) ini_get( 'disable_functions' );
847
+ if ( function_exists( 'set_time_limit' ) && false === strpos( $disabled, 'set_time_limit' ) ) {
848
+ set_time_limit( 600 );
849
+ }
850
+ }
851
+
852
+ /**
853
+ * Fingerprint as sent by the server, or "".
854
+ *
855
+ * @param mixed $fp Value.
856
+ * @return string
857
+ */
858
+ private static function clean_fingerprint( $fp ) {
859
+ return ( is_string( $fp ) && preg_match( '/^[A-Za-z0-9:_\-]{1,128}$/', $fp ) ) ? $fp : '';
860
+ }
861
+
862
+ /**
863
+ * JSON safe inside <script> and for invalid UTF-8; null on failure.
864
+ *
865
+ * @param mixed $data Data.
866
+ * @return string|null
867
+ */
868
+ private static function json( $data ) {
869
+ $j = json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_INVALID_UTF8_SUBSTITUTE ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- flags wp_json_encode does not add.
870
+ return false === $j ? null : $j;
871
+ }
872
+
873
+ /**
874
+ * HTML-escape.
875
+ *
876
+ * @param string $s Text.
877
+ * @return string
878
+ */
879
+ private static function h( $s ) {
880
+ return htmlspecialchars( (string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8' );
881
+ }
882
+
883
+ /**
884
+ * Cut to $max characters.
885
+ *
886
+ * @param string $s Text.
887
+ * @param int $max Max.
888
+ * @return string
889
+ */
890
+ private static function cut( $s, $max ) {
891
+ $s = trim( (string) $s );
892
+ return function_exists( 'mb_substr' ) ? mb_substr( $s, 0, $max, 'UTF-8' ) : substr( $s, 0, $max );
893
+ }
894
+ }