Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ jobs:
run: |
composer require --dev --no-progress --no-interaction -W \
typo3/cms-core:^13.4 typo3/cms-backend:^13.4 \
typo3/cms-install:^13.4 typo3/cms-dashboard:^13.4
typo3/cms-install:^13.4 typo3/cms-dashboard:^13.4 \
typo3/cms-workspaces:^13.4

# Two more constraints than the v13 leg needs. phpunit has to come down
# with the rest: typo3/cms-install ^12.4 and phpunit ^11 cannot be
Expand All @@ -105,7 +106,8 @@ jobs:
composer config policy.advisories.block false
composer require --dev --no-progress --no-interaction -W \
typo3/cms-core:^12.4 typo3/cms-backend:^12.4 typo3/cms-install:^12.4 \
typo3/cms-dashboard:^12.4 typo3/testing-framework:^8.0 \
typo3/cms-dashboard:^12.4 typo3/cms-workspaces:^12.4 \
typo3/testing-framework:^8.0 \
phpunit/phpunit:^10.5

- name: Lint
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@

## Unreleased

- **Bugfix "Workspace versioning of prompt fragments"**: `tx_aim_prompt_fragment` and
`tx_aim_page_prompt_fragment` set `versioningWS_alwaysAllowLiveEdit` next to `versioningWS`, so an edit made in
a workspace went straight to live, unlike the workspace behaviour `Documentation/ToneOfVoice.md` describes.
TYPO3 14.3.8 removes the option from workspace aware tables and logs a TCA migration deprecation for both
tables, which made test suites with `failOnDeprecation` fail as soon as aim was loaded. The option is gone; on
TYPO3 12.4, 13.4 and 14.0 to 14.3.7 a fragment or an assignment edited in a workspace is now versioned like on
14.3.8 and later. The tone of voice resolved within a workspace now also follows an assignment activated,
deactivated or created in that workspace. The Prompt Management module and `PromptManagementApi` show the
fragments and assignments of the backend user's workspace instead of mixing live records and workspace versions;
the paginated Pages and Library listings filter, sort and count live records plus the ones created in that
workspace, like the core Records module, and leave out what is deleted there. `aim:calibrateVoice` and
`PromptManagementApi::saveAutoDetectedFragment()` still write live and read live records only, but detach the
auto-detected fragment into a new one when an assignment created or edited in any workspace reuses it, and a
pending workspace version of the assignment follows that detach. `saveAutoDetectedFragment()` now throws a
`LogicException` when called within a workspace, instead of writing live from there. A workspace version of the
fragment itself still replaces what they refreshed in place once it is published. Within a workspace, the tone
of voice follows the assignment order of that workspace, and the Library counts a fragment moved there once.

- **Bugfix "Smart routing with dated model names"**: Smart routing never downgraded a request to OpenAI
or Anthropic. The request log stores the model name a provider reports, a dated snapshot
(`gpt-4o-mini-2024-07-18`) or the target of an alias (`claude-sonnet-4-20250514` for `claude-sonnet-4-0`),
Expand Down
565 changes: 348 additions & 217 deletions Classes/Domain/Repository/PagePromptFragmentRepository.php

Large diffs are not rendered by default.

69 changes: 49 additions & 20 deletions Classes/Prompt/PagePromptResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Core\Utility\GeneralUtility;
Expand Down Expand Up @@ -55,8 +56,9 @@
* boundaries; that's an established, technical-audience convention
* (integrators can already use the `>` clear operator).
*
* DB fragments respect workspace overlays for edits to either the
* assignment or the library fragment itself. Not translatable: neither
* DB fragments respect workspaces for either the assignment or the library
* fragment itself: an edit, an activation, a deactivation, a deletion and
* a record created within the current workspace. Not translatable: neither
* TCA has language/transOrig configuration, since nothing in the
* resolution pipeline is language-aware yet.
*
Expand Down Expand Up @@ -98,8 +100,7 @@ public function resolve(int $pageId, PromptFragmentScope $scope): ?string
// can legitimately resolve differently per workspace; caching by
// pageId+scope alone would let a live-workspace resolution leak into
// a later draft-workspace one within the same instance's lifetime.
$workspaceId = (int)$this->context->getPropertyFromAspect('workspace', 'id');
$cacheKey = $pageId . ':' . $scope->value . ':' . $workspaceId;
$cacheKey = $pageId . ':' . $scope->value . ':' . $this->getWorkspaceId();
if (array_key_exists($cacheKey, $this->cache)) {
return $this->cache[$cacheKey];
}
Expand Down Expand Up @@ -217,20 +218,23 @@ private function fetchPageTsConfigFragments(int $pageId, PromptFragmentScope $sc
* instead lets an invalid value be normalized to "all" with a logged
* warning, the same treatment as the TSconfig/registry sources.
*
* Restricted to the live workspace at the query level (a table with no
* such restriction would otherwise mix in every other workspace's
* versions too, not just the current one), then overlaid per-row with
* the current workspace's version via versionOL(), the standard core
* idiom for "show the workspace-draft edit of this record, if any."
* Restricted to live records plus the ones created new in the current
* workspace at the query level, then overlaid per-row with the current
* workspace's version via versionOL(), the standard core idiom for
* "show the workspace-draft edit of this record, if any." Hidden rows
* are only dropped after the overlay, so an assignment activated or
* deactivated within a workspace resolves the way that workspace sees it.
*
* @param list<int> $pageIds
* @return array<int, list<array{prompt: string, examples: string, inheritToSubpages: bool}>> keyed by parent_page, ordered by sorting
*/
private function fetchFragmentsByPage(array $pageIds, PromptFragmentScope $scope): array
{
$assignmentQueryBuilder = $this->connectionPool->getQueryBuilderForTable(self::ASSIGNMENT_TABLE);
$assignmentQueryBuilder->getRestrictions()->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, 0));
$assignmentRows = $assignmentQueryBuilder->select('uid', 'pid', 't3ver_oid', 't3ver_state', 'parent_page', 'fragment', 'inherit_to_subpages')
$assignmentQueryBuilder->getRestrictions()
->removeByType(HiddenRestriction::class)
->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->getWorkspaceId()));
$assignmentRows = $assignmentQueryBuilder->select('uid', 'pid', 't3ver_wsid', 't3ver_oid', 't3ver_state', 'hidden', 'sorting', 'parent_page', 'fragment', 'inherit_to_subpages')
->from(self::ASSIGNMENT_TABLE)
->where(
$assignmentQueryBuilder->expr()->in('parent_page', $assignmentQueryBuilder->createNamedParameter($pageIds, Connection::PARAM_INT_ARRAY)),
Expand All @@ -244,12 +248,13 @@ private function fetchFragmentsByPage(array $pageIds, PromptFragmentScope $scope

$liveAssignments = [];
foreach ($assignmentRows as $row) {
$pageRepository->versionOL(self::ASSIGNMENT_TABLE, $row);
if ($row === false) {
continue; // deleted within the current workspace
$row = $this->overlay(self::ASSIGNMENT_TABLE, $row, $pageRepository);
if ($row !== null) {
$liveAssignments[] = $row;
}
$liveAssignments[] = $row;
}
// The query sorted live rows; a workspace can reorder them.
usort($liveAssignments, static fn(array $a, array $b): int => [(int)$a['sorting'], (int)$a['uid']] <=> [(int)$b['sorting'], (int)$b['uid']]);

$fragmentUids = array_values(array_unique(array_map(
static fn(array $row): int => (int)$row['fragment'],
Expand Down Expand Up @@ -286,8 +291,10 @@ private function fetchFragmentsByUid(array $fragmentUids, PageRepository $pageRe
}

$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::FRAGMENT_TABLE);
$queryBuilder->getRestrictions()->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, 0));
$rows = $queryBuilder->select('uid', 'pid', 't3ver_oid', 't3ver_state', 'prompt', 'examples', 'scope')
$queryBuilder->getRestrictions()
->removeByType(HiddenRestriction::class)
->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->getWorkspaceId()));
$rows = $queryBuilder->select('uid', 'pid', 't3ver_wsid', 't3ver_oid', 't3ver_state', 'hidden', 'prompt', 'examples', 'scope')
->from(self::FRAGMENT_TABLE)
->where(
$queryBuilder->expr()->in('uid', $queryBuilder->createNamedParameter($fragmentUids, Connection::PARAM_INT_ARRAY)),
Expand All @@ -297,9 +304,9 @@ private function fetchFragmentsByUid(array $fragmentUids, PageRepository $pageRe

$byUid = [];
foreach ($rows as $row) {
$pageRepository->versionOL(self::FRAGMENT_TABLE, $row);
if ($row === false) {
continue; // deleted within the current workspace
$row = $this->overlay(self::FRAGMENT_TABLE, $row, $pageRepository);
if ($row === null) {
continue;
}
$byUid[(int)$row['uid']] = [
'prompt' => trim((string)$row['prompt']),
Expand All @@ -309,4 +316,26 @@ private function fetchFragmentsByUid(array $fragmentUids, PageRepository $pageRe
}
return $byUid;
}

/**
* A move pointer is skipped: versionOL() of its live row already carries
* the move. Null for a row deleted or hidden as the current workspace
* sees it.
*/
private function overlay(string $table, array $row, PageRepository $pageRepository): ?array
{
if ((int)$row['t3ver_oid'] > 0) {
return null;
}
$pageRepository->versionOL($table, $row, false, true);
if (!is_array($row) || (int)$row['hidden'] === 1) {
return null;
}
return $row;
}

private function getWorkspaceId(): int
{
return (int)$this->context->getPropertyFromAspect('workspace', 'id');
}
}
7 changes: 7 additions & 0 deletions Classes/Service/PromptManagementApi.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,14 @@ public function countAllFragmentAssignments(): FragmentAssignmentCounts
* is saved. Pass $hidden = false only where a human has already approved
* the text. Re-running never re-hides an assignment somebody activated.
*
* The fragment is written live, so this throws within a workspace, where
* both tables are versioned. A workspace version of the fragment still
* shows its own text in findToneOfVoice() there, and publishing it
* replaces what was saved here, unless the save detached the assignment
* into a new fragment.
*
* @param list<string> $scope
* @throws \LogicException within a workspace
*/
public function saveAutoDetectedFragment(
int $rootPageId,
Expand Down
1 change: 0 additions & 1 deletion Configuration/TCA/tx_aim_page_prompt_fragment.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
'disabled' => 'hidden',
],
'versioningWS' => true,
'versioningWS_alwaysAllowLiveEdit' => true,
'security' => [
'ignorePageTypeRestriction' => true,
],
Expand Down
1 change: 0 additions & 1 deletion Configuration/TCA/tx_aim_prompt_fragment.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
'disabled' => 'hidden',
],
'versioningWS' => true,
'versioningWS_alwaysAllowLiveEdit' => true,
'security' => [
'ignorePageTypeRestriction' => true,
],
Expand Down
4 changes: 3 additions & 1 deletion Documentation/ToneOfVoice.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ A page can also check **"Disable inherited prompt fragments"** to skip every anc

DB fragment inheritance stops at the nearest `is_siteroot` ancestor: in a nested-site install (one site's page tree living under another site's), a page's own assignments never leak into an unrelated site. Page TSconfig fragments deliberately don't get this treatment; `getPagesTSconfig()` has never respected site boundaries, and that's an established, technical-audience convention this extension doesn't override.

DB fragments respect workspace overlays for edits to *either* the assignment or the library fragment's own content: an edit made in a workspace is visible when resolving within that workspace. A fragment or assignment created entirely new within a workspace (no live counterpart yet) won't appear until published.
DB fragments respect workspaces for *either* the assignment or the library fragment's own content: an edit, an activation or deactivation, a deletion and a fragment or assignment created new within a workspace are visible when resolving within that workspace, and only there until published.

To have a request resolve against a page, pass `pageId`:

Expand Down Expand Up @@ -157,6 +157,8 @@ Requests without a `pageId` (the default) use the `defaultSystemPrompt` Extensio

`saveAutoDetectedFragment()` writes **inactive by default** (`$hidden = true`). What it saves is machine-derived, usually from page copy nobody has vetted, and it would otherwise apply to every AI request in that tree the moment it is written. Pass `$hidden = false` only where a human has already approved the text. A re-run never re-hides an assignment somebody activated. `$source` identifies the calling feature and drives `findToneOfVoice()`'s `isCliCalibrated`; it is not derived from `$title`, which an editor stays free to rename.

`saveAutoDetectedFragment()` writes live, without DataHandler, so it throws a `LogicException` when the backend user works in a workspace. Offer the action in the live workspace only.

### Robustness notes

- **Unknown scope values normalize to `all` with a logged warning**, across every source (DB, Page/User TSconfig, code-registered): a typo (`imageGeneraton`) makes a fragment apply everywhere rather than silently never firing. Far more noticeable, and thus fixable.
Expand Down
Loading
Loading