Skip to content

v1.1.0

Latest

Choose a tag to compare

@github-actions github-actions released this 01 Aug 07:51
4a54aab

Added

  • Search as you type: ten config keys, so the suggestions dropdown is reachable from Laravel (config/scolta.php, .env.example, resources/views/components/search.blade.php, README.md; tag1consulting/scolta-php#247). The published scolta.js now suggests while a visitor types, merging their own recent searches ahead of content suggestions; the full pipeline — AI query expansion, the AI summary, follow-ups — still runs only on Enter, on the search button, or on selecting a suggestion. On by default and no index rebuild is needed: suggestions read the index the site already has. Ten new top-level keys, each with an env() default byte-equal to the browser bundle's own fallback: sayt_enabled (true, SCOLTA_SAYT_ENABLED), sayt_min_chars (2, counted in graphemes so an emoji is one character; CJK sites commonly want 1), sayt_debounce_ms (150), sayt_max_suggestions (6), sayt_recent_searches (true, one localStorage key, nothing read or written when off), sayt_max_recent (3), sayt_expand (true), sayt_expand_per_minute (6), sayt_expansion_delay_ms (500) and sayt_suggestion_action (navigate or search). sayt_enabled set to false restores the pre-1.1.0 widget exactly: no dropdown node, no combobox ARIA roles on the input, no browser storage access, no suggest searches. The per-minute cap is not arbitrary: SAYT expansions share the AI flood budget with committed searches, since expansion, summarize and follow-up all count against the same per-IP limit, so an unbudgeted suggest path would spend a visitor's whole allowance on prefixes and starve the search they actually ran; over the cap the dropdown degrades to keyword-only suggestions until the window rolls. All ten are top-level keys, not a sayt group and not scoring keys, for the same reason hide_empty_facets is: mergeConfigFrom() is a shallow array_merge(), so a published config predating them still picks up the package defaults at the top level, while a published group would replace the package's group whole and take every default in it. php artisan config:cache is the one case that does not: mergeConfigFrom() is skipped entirely when the configuration is cached, so a cached config built before this release needs config:cache re-run. The README says both. The Blade component hand-builds window.scolta, so all ten are added there too, with sayt_suggestion_action passed through ScoltaConfig::normalizedSaytSuggestionAction() so an unrecognized configured value reaches the browser as navigate rather than as itself. flattenConfig()'s MAP_VALUED_KEYS deliberately gains no entries — every SAYT setting is a scalar, and the flattener already passes scalars through — with a regression test at the flattener's own layer pinning that, because adding them there would look harmless and pass every emission test right up until someone grouped them. Covered by tests/ConfigTest.php (each key present, each default byte-equal to the browser fallback, each key top-level and no sayt group), tests/ScoltaConfigIntegrationTest.php (all ten survive flattenConfig(), MAP_VALUED_KEYS unchanged, defaults and overrides reach the typed properties, string-valued env input casts to the declared types, unknown action normalizes) and tests/SearchComponentRenderTest.php (all ten emitted with their defaults, a published-config sayt_enabled of false reaching the browser, every other key overridden through the same published-config path, and the unknown-action normalization). The existing parity guard needed no change and is not passing vacuously: dropping a single emitted key fails it with toBrowserConfig() emits saytMaxRecent but the Blade component does not.
  • hide_empty_facets config key, so the facet-visibility opt-out is reachable from Laravel (config/scolta.php, .env.example, README.md). New top-level key (default true, SCOLTA_HIDE_EMPTY_FACETS). The published scolta.js has read instanceConfig.hideEmptyFacets since it was re-vendored, and the bundle treats an absent key as "hide" (only a literal false disables it), so without a config key the opt-out was unreachable: an operator had to set window.scolta.hideEmptyFacets by hand after the fact. Deliberately top-level rather than nested under scoring, because the service provider's mergeConfigFrom is a shallow array_merge: an operator whose published config predates this key still picks up the package default only for a top-level key.
  • The six specificity and co-occurrence ranking keys are now configurable (config/scolta.php, .env.example, README.md). scoring.specificity_weighting (default true), scoring.specificity_floor (0.15), scoring.specificity_strong_match (0.55), scoring.specificity_cooccurrence (0.9), scoring.specificity_agreement_gate (0.45) and scoring.specificity_agreement_decay (1.0), each with a SCOLTA_SPECIFICITY_* environment variable per this config file's convention. The browser has read all six for some time and scolta-php now carries all six as typed properties, but Laravel exposed none, so every app was pinned to the hardcoded JS fallbacks. None of the six appears in any ScoltaConfig::PRESETS entry, so unlike the preset-overridable scoring keys they take concrete defaults rather than a bare null; a null here would reach ScoltaConfig as null and lose the documented default, which tests/ConfigTest.php now pins. Defaults are byte-equal to the JS fallbacks, so no existing app's ranking moves. No mapping code is needed: ScoltaAiService::flattenConfig() hoists scoring sub-keys to the top level generically and hands everything to ScoltaConfig::fromArray().
  • A Laravel-flavoured browser-config parity guard (tests/SearchComponentRenderTest.php). Asserts that every key ScoltaConfig::toBrowserConfig() emits is also emitted by the Blade component, which hand-builds its config array rather than calling toBrowserConfig() and so drifts silently every time scolta-php gains a browser key. That is exactly how the two bridges fixed below went missing. The JS-extraction form of this guard used in the other five packages is impossible here: this package ships no committed scolta.js (it publishes the bundle out of the composer-installed scolta-php at install time), so there is no in-repo artifact to parse. A reverse-direction assertion covers the other way, allowlisting the four documented platform additions (container, currentLanguage, allowedLinkDomains, disclaimer). What this form cannot catch is a key the browser reads that scolta-php itself does not emit; that gap is covered upstream by scolta-php's own parity test.

Fixed

  • scolta:build aborted with a raw hash_init() error on an app that had not run key:generate, naming neither APP_KEY nor the remedy (src/Commands/BuildCommand.php, src/Services/QueueRebuildDispatcher.php, new src/Support/HmacSecret.php). config('app.key') on an application without a generated key is string(0) "", not null, and both call sites forwarded it unguarded into the PHP indexer, where hash_init('sha256', HASH_HMAC, '') throws. So php artisan scolta:build --force died with Index build failed: hash_init(): Argument #3 ($key) must not be empty when HMAC is requested on the first command a new adopter runs, and because the message mentioned neither the setting nor the fix, it read as a Scolta bug rather than an unconfigured app. An empty APP_KEY now resolves to null, so chunk integrity tagging is skipped and the build completes; CRC32 corruption detection is unaffected. The build is deliberately not failed. An index without an integrity tag is a working index, and hard-failing would block the demo and evaluation path for no security gain, since an operator with no APP_KEY was never getting a tag either way. Whitespace-only keys resolve to null on the same reasoning, and a key with real content beside whitespace is used verbatim rather than trimmed, so an index already built under a padded key keeps verifying. Both call sites are fixed, not just the reported one. QueueRebuildDispatcher reads config('app.key') independently and forwards it to BuildCoordinator and to every dispatched ProcessIndexChunk and FinalizeIndex job, so a fix confined to BuildCommand would have left the observer-driven queued rebuild throwing the same error inside a worker process, where nobody is watching for it. A warning naming APP_KEY and php artisan key:generate is now emitted on both paths, stating that integrity tagging is off and that CRC32 stays active: to the console on the synchronous path, and to the log on the queued path, since there is no console there and the log is where an operator debugging a queued rebuild is already looking. The message lives in one place (HmacSecret::EMPTY_APP_KEY_WARNING_LINES) so the two paths cannot drift into describing the same condition differently, and is held as separate short lines because Symfony hard-wraps at the terminal width and a wrap landing inside php artisan key:generate would break the one part the operator has to copy. This is complementary to the library-side fix in tag1consulting/scolta-php#249, not replaced by it. That change stops the library crashing for every adapter; this one makes the Laravel-specific failure actionable, and it is verified against an unpatched scolta-php so it does not depend on that release. Covered by the new tests/EmptyAppKeyBuildTest.php (11 cases: the sync build completing and publishing a Pagefind index with APP_KEY=; the warning naming APP_KEY, php artisan key:generate, the disabled integrity tag and CRC32; no warning when the key is set; a whitespace-only key treated as unset; the queued dispatch completing; both ProcessIndexChunk and the chained FinalizeIndex carrying null rather than ''; the queued path logging the warning; a configured key still reaching the workers; and '0', which is falsy in PHP, surviving as real key material, which is why the coercion tests trim() rather than using ?:). Reported as #110.
  • The search widget never received filterFieldDescriptions or hideEmptyFacets, so both features were dead on Laravel (resources/views/components/search.blade.php). scolta.js reads instanceConfig.filterFieldDescriptions to label filter groups and instanceConfig.hideEmptyFacets to control zero-count facet visibility. scolta-php emits both from toBrowserConfig(), but the Blade component hand-builds its $scoltaConfig array and omitted both, so filter descriptions never appeared and the facet opt-out could not be reached at all. Root cause is not the omission but that nothing could catch it: the component duplicates toBrowserConfig() by hand with no assertion tying the two together, so a key added upstream simply never arrived here and no test noticed. Both are now emitted at the top level (siblings of siteName, not inside scoring), and the new parity assertion in tests/SearchComponentRenderTest.php fails if the component falls behind toBrowserConfig() again.
  • Follow-up questions no longer fail validation when the conversation carries its AI-Overview context. FollowUpController capped each message at 10,000 characters and the whole conversation at 50,000, but the search UI seeds the follow-up conversation with the full AI-Overview context (the same search-result excerpts the summarize endpoint accepts, routinely 10k to 50k characters) as the first user turn, followed by the summary and then the question. That first turn blew the per-message cap, so every follow-up on an AI search was rejected and the UI rendered "Follow-up unavailable. Please try again." The controller was stricter than the layer it feeds: AiEndpointHandler in scolta-php has always allowed 100,000 characters per message and 400,000 in total. Both controller constants are now aligned with the handler's (FOLLOW_UP_MAX_MESSAGE_CHARS, FOLLOW_UP_MAX_TOTAL_CHARS) so the two validation layers cannot disagree and reject the application's own legitimate payload. The message-count cap (25) and scolta.max_follow_ups are unchanged. Covered by the updated size-cap assertions in FollowUpValidationTest plus a new regression test that posts a conversation seeded with a ~48k-character first turn.

Changed

  • tag1/scolta-php is now required at ^1.1.0, and extra.branch-alias maps dev-main onto 1.1.x-dev (composer.json, composer.lock). resources/views/components/search.blade.php reads eleven ScoltaConfig members that do not exist in scolta-php 1.0.5: hideEmptyFacets and the nine sayt* properties, plus a call to normalizedSaytSuggestionAction(). The old ^1.0.5 constraint admitted that version, and the method call makes it fatal rather than merely degraded: against 1.0.5 the ten property reads emit a PHP warning each and ship null to the browser, and the method call then raises Error: Call to undefined method. The branch alias had said 1.0.x-dev since the 0.3 series while this line has been 1.1.0-dev since 2026-07-28, so a consumer resolving dev-main from a VCS repository was aliased onto the 1.0 line and would not have satisfied the new constraint. This matters here in particular because several demos and sml3 pin adapter dev branches. Composer's lock content hash covers extra, so the lock is regenerated with it.
  • The managed Amazee.ai gateway is enabled explicitly, through the settings page or the provision command, instead of on the first AI request (src/ScoltaServiceProvider.php, src/Http/Controllers/AmazeeSettingsController.php, src/Commands/AmazeeProvisionCommand.php, resources/views/amazee-settings.blade.php, config/scolta.php, README.md). Two surfaces enable it, each from an operator action: the Amazee.ai settings page and php artisan scolta:amazee:provision. Both are unchanged in mechanics. What changed is everything else: the ScoltaAiService factory now only reads a connection one of those surfaces established, and establishes none of its own, so serving a request can never turn AI on. ⚠️ Upgrade note: an application that expected AI features to appear without configuring anything must now enable the gateway once, from either surface; an already-stored connection keeps working untouched and needs no action. With no SCOLTA_API_KEY and no stored connection the factory builds a key-less client, so /api/scolta/v1/expand, /summarize and /follow-up return HTTP 200 with the query unexpanded and no summary — the same graceful degrade as any unconfigured provider, never a 4xx — and keyword search is unaffected either way. Moving off the gateway now clears it. Configuring an explicit SCOLTA_API_KEY clears the stored connection and both recovery markers (the auth-failure marker and the persistent re-authentication marker), and switching off from the settings page does the same. Leaving them behind was its own small bug: the explicit key already won at the factory, so the stored connection was unreachable, yet /health and the settings page kept describing it, and a "reconnect your account" prompt kept showing for credentials the operator had deliberately moved off. The clear costs one indexed lookup that finds nothing on a site that never connected. One consequence worth stating: a stored connection whose model names never resolved is no longer re-resolved from a request path. It still degrades safely (no key is injected, so the gateway never receives the shipped dated default and answers 200 rather than 400), and re-running either enable surface resolves and stores the model names. Both surfaces now state the offer in the same words, held as AmazeeProvisionCommand::OFFER_LINE with a test asserting the settings page carries that exact sentence, so the two cannot drift into describing the same offer differently. Covered by the new tests/AiServiceOptInResolutionTest.php (resolving with nothing configured stores nothing and yields a degrading client; a stored connection drives the gateway; an explicit key clears the connection and both markers), tests/Http/AmazeeSettingsOptInTest.php (switching off clears the connection and both markers; the page states the offer and reports each state) and tests/Commands/AmazeeProvisionCommandTest.php (the command states the offer, stands aside for an explicitly configured provider, and rejects a malformed address), plus tests/AiProviderOptInTest.php and tests/AmazeeUnresolvedModelTest.php, which replace the former structural suites: no provisioning reference survives anywhere in the service provider, both enable surfaces keep their provisioner call, and the key is injected only once a real model name is stored.
  • Pagefind index chunks are preloaded while the user types (tag1consulting/scolta-php#232, issue #191). Scolta runs no search until Enter or the search button, so every submitted search also paid for fetching the alphabetical index chunk(s) for the typed term. The search input now hands the term to pagefind.preload() — the chunk-resolution half of a search, which bails out before scoring — behind a 150 ms trailing debounce, a 2-character floor, a repeat-term skip and a feature-detect on preload; failures are swallowed, so a cache warm can never break the search box.

Note: scolta-laravel serves scolta.js directly from the scolta-php dependency (no committed copy), so this ships automatically once the scolta-php dependency is updated to a release containing it. AssetStatus validates the published copy against the dependency's assets/js/scolta.js.sha256, so run php artisan vendor:publish --tag=scolta-assets --force after the upgrade or the status check will report drift. No Laravel-side code changed.

Known limitations

  • Facet counts do not exactly match the filtered result list once AI query expansion has run (tag1consulting/scolta-php#265). The panel recounts after expansion, which it did not do before this release, and on every corpus measured it is strictly closer to the list than the previous behaviour was. It is not yet exact. It reaches this package through the scolta.js served from the scolta-php dependency. Unexpanded queries are exact. Targeted for 1.1.1; see the scolta-php issue for the diagnosis and the fixes considered.