\nSHORT: \nDESCRIPTION:\n\n" . "In DESCRIPTION use plain text only: blank lines between paragraphs, '## ' before a heading, '- ' before a list item. Do not use HTML, links, code or tables. " . "Do not invent specifications, materials, dimensions, certifications, prices, discounts, stock claims, awards or reviews; where the brief lacks a fact, keep the wording general."; public function __construct(Ledger $ledger, AuditLog $audit) { $this->ledger = $ledger; $this->audit = $audit; } /** * @param array{title:string, type:string, description?:string, short?:string} $product only what may be sent; descriptions only when the administrator chose to use them as reference * @return array{title:string, short:string, description:string, model:string, input:int, output:int} * @throws ContentStudioException */ public function generate(Principal $principal, string $requestId, int $productId, array $product, string $brief, ModelAdapter $adapter, string $model, ?int $connectionId, string $tone = '', string $language = ''): array { if (!$principal->isAuthenticated() || !$principal->can(Capabilities::USE_WOOCOMMERCE_AI)) { throw new ContentStudioException('forbidden'); } if (!preg_match('/^wc_[a-f0-9]{24}$/', $requestId) || $productId <= 0 || $model === '' || strlen($model) > 128 || !in_array($product['type'] ?? '', self::TYPES, true) || !WritingStyle::validTone($tone) || !WritingStyle::validLanguage($language)) { throw new ContentStudioException('invalid_request'); } $brief = trim((string) preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', str_replace("\r\n", "\n", $brief))); if (mb_strlen($brief) < self::BRIEF_MIN || mb_strlen($brief) > self::BRIEF_MAX || !preg_match('/\p{L}/u', $brief)) { throw new ContentStudioException('invalid_brief'); } $context = []; foreach (['description' => 'Current long description of the product', 'short' => 'Current short description of the product'] as $key => $label) { $text = ContentFormatter::plainLine((string) ($product[$key] ?? ''), self::REFERENCE_MAX); if ($text !== '') { $context[] = ['n' => count($context) + 1, 'title' => $label, 'heading' => '', 'text' => $text]; } } $site = $principal->siteId; $actor = $principal->actorRef(); $maxOut = min(self::MAX_OUTPUT, (int) ($adapter->capabilities()['max_output_tokens'] ?? self::MAX_OUTPUT)); $message = 'Product name: ' . ContentFormatter::plainLine((string) $product['title'], 200) . "\nProduct type: " . $product['type'] . "\n\nBrief:\n" . $brief; $request = new ModelRequest($model, self::INSTRUCTION . WritingStyle::instruction($tone, $language),[['role' => 'user', 'content' => $message]], $maxOut, $requestId, self::TIMEOUT, $context); $units = ($adapter instanceof RequestBudget ? $adapter->inputReservation($request) : $request->estimatedInputTokens()) + $maxOut; if ($this->ledger->balance($site, 'site') === null) { throw new ContentStudioException('no_budget'); } try { $reservation = $this->ledger->reserve($site, $requestId, ['site'], $units); } catch (SpendingHaltedException $exception) { throw new ContentStudioException('paused'); } catch (BudgetExceededException $exception) { $this->audit->record($site, $actor, 'woocommerce_ai.generate', 'product:' . $productId, 'budget_denied', $requestId); throw new ContentStudioException('budget_exceeded'); } if ($reservation->duplicate) { throw new ContentStudioException($reservation->state === Reservation::UNCERTAIN ? 'outcome_uncertain' : 'duplicate', $requestId); } $simulated = (bool) ($adapter->capabilities()['simulated'] ?? false); $this->ledger->attribute($site, $requestId, $simulated ? Reservation::ORIGIN_SIMULATED : Reservation::ORIGIN_WOOCOMMERCE_AI, null, $connectionId ?? ($adapter instanceof ConnectionBound ? $adapter->connectionId() : null), $model); $this->ledger->markDispatched($site, $requestId); $basis = $simulated ? Reservation::BASIS_SIMULATED : (($adapter->capabilities()['usage'] ?? '') === 'reported' ? Reservation::BASIS_REPORTED : Reservation::BASIS_ESTIMATED); try { $result = $adapter->complete($request, CancellationToken::none()); } catch (ProviderException $e) { if (!$e->dispatched) { $this->ledger->settle($site, $requestId, 0, null, Reservation::BASIS_NOT_DISPATCHED); } elseif ($e->rejected) { $this->ledger->settle($site, $requestId, 0, null, Reservation::BASIS_REJECTED); } elseif ($e->usageTokens !== null) { $this->ledger->settle($site, $requestId, $e->usageTokens, null, $basis); } else { $this->ledger->markUncertain($site, $requestId, 'woocommerce-ai-' . $e->kind); } $this->audit->record($site, $actor, 'woocommerce_ai.generate', 'product:' . $productId, $e->dispatched && !$e->rejected && $e->usageTokens === null ? 'uncertain' : 'failed', $requestId, $e->kind); throw new ContentStudioException('provider_' . $e->kind, $requestId); } catch (\Throwable $exception) { $this->ledger->markUncertain($site, $requestId, 'woocommerce-ai-unexpected'); $this->audit->record($site, $actor, 'woocommerce_ai.generate', 'product:' . $productId, 'uncertain', $requestId); throw new ContentStudioException('outcome_uncertain', $requestId); } $this->ledger->settle($site, $requestId, $result->totalTokens(), $result->providerRef, $basis, $result->inputTokens, $result->outputTokens, $result->detail); $proposal = self::parse($result->text); if ($proposal['title'] === '' && $proposal['short'] === '' && $proposal['description'] === '') { $this->audit->record($site, $actor, 'woocommerce_ai.generate', 'product:' . $productId, 'empty_output', $requestId); throw new ContentStudioException('empty_output', $requestId); } $this->audit->record($site, $actor, 'woocommerce_ai.generate', 'product:' . $productId, 'ok', $requestId); return $proposal + ['model' => $model, 'input' => $result->inputTokens, 'output' => $result->outputTokens]; } /** @return array{title:string, short:string, description:string} HTML built here from escaped plain text; any field may be empty */ public static function parse(string $reply): array { $reply = str_replace(["\r\n", "\r"], "\n", $reply); $reply = (string) preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $reply); $title = preg_match('/^[ \t]*TITLE:[ \t]*(.*)$/mi', $reply, $m) ? $m[1] : ''; $short = preg_match('/^[ \t]*SHORT:[ \t]*(.*)$/mi', $reply, $m) ? $m[1] : ''; $body = preg_match('/^[ \t]*DESCRIPTION:[ \t]*\n?(.*)\z/msi', $reply, $m) ? $m[1] : ''; if ($title === '' && $short === '' && $body === '' && !preg_match('/^[ \t]*(TITLE|SHORT|DESCRIPTION):/mi', $reply)) { $body = $reply; // no markers at all: the whole reply is the long description } return [ 'title' => ContentFormatter::plainLine($title, ContentFormatter::TITLE_MAX), 'short' => ContentFormatter::toHtml(mb_substr(trim($short), 0, 1000)), 'description' => ContentFormatter::toHtml($body), ]; } } // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped