add suggestion management
PHP Tests / php-tests (push) Waiting to run

This commit is contained in:
Nyan Lin Paing
2026-09-01 00:16:17 +07:00
parent b8d31e3dc4
commit 31ed52500a
12 changed files with 1226 additions and 5 deletions
@@ -3,6 +3,7 @@
namespace Modules\Shared\Bnfexpress;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
use Modules\Shared\Bnfexpress\Support\BnfexpressSignature;
@@ -163,6 +164,159 @@ class BnfexpressAdminClient
return $this->request('GET', "/admin/ev/history/{$userId}/{$sessionId}");
}
// --- Suggestions ------------------------------------------------------
/**
* @return array<string, mixed>
*/
public function listSuggestions(?string $q = null, ?int $limit = null, ?int $offset = null): array
{
return $this->request('GET', '/admin/suggestions', query: array_filter([
'q' => $q,
'limit' => $limit,
'offset' => $offset,
], fn (mixed $value): bool => $value !== null));
}
/**
* @return array<string, mixed>
*/
public function getSuggestion(int|string $id): array
{
return $this->request('GET', "/admin/suggestions/{$id}");
}
/**
* @return array<string, mixed>
*/
public function createSuggestion(string $textDisplay, string $lang, ?string $intent = null, int $weight = 0, string $source = 'admin'): array
{
return $this->request('POST', '/admin/suggestions', body: [
'text_display' => $textDisplay,
'lang' => $lang,
'intent' => $intent,
'weight' => $weight,
'source' => $source,
]);
}
/**
* @return array<string, mixed>
*/
public function updateSuggestion(int|string $id, ?string $textDisplay = null, ?string $lang = null, ?string $intent = null, ?int $weight = null, ?string $source = null): array
{
return $this->request('PATCH', "/admin/suggestions/{$id}", body: array_filter([
'text_display' => $textDisplay,
'lang' => $lang,
'intent' => $intent,
'weight' => $weight,
'source' => $source,
], fn (mixed $value): bool => $value !== null));
}
/**
* @return array<string, mixed>
*/
public function deleteSuggestion(int|string $id): array
{
return $this->request('DELETE', "/admin/suggestions/{$id}");
}
/**
* @param list<array{text: string, lang: string, intent?: string|null}> $items
* @return array{created: int, skipped: int, trie_rebuilt: bool}
*/
public function batchCreateSuggestions(array $items): array
{
return $this->request('POST', '/admin/suggestions/batch', body: ['items' => $items]);
}
/**
* @param list<int|string> $ids
* @return array{deleted: int, skipped: int}
*/
public function batchDeleteSuggestions(array $ids): array
{
return $this->request('DELETE', '/admin/suggestions/batch', body: ['ids' => $ids]);
}
// --- Suggestion misses --------------------------------------------------
/**
* @return array<int, array<string, mixed>>
*/
public function listSuggestionMisses(?bool $wasUsed = null, ?int $limit = null, ?int $offset = null): array
{
return $this->request('GET', '/admin/suggestion-misses', query: array_filter([
'was_used' => $wasUsed,
'limit' => $limit,
'offset' => $offset,
], fn (mixed $value): bool => $value !== null));
}
/**
* @return array<string, mixed>
*/
public function dismissSuggestionMiss(int|string $id): array
{
return $this->request('DELETE', "/admin/suggestion-misses/{$id}");
}
/**
* @param list<int|string> $missIds
* @return array{created: int, skipped: int, trie_rebuilt: bool}
*/
public function promoteSuggestionMisses(array $missIds, ?string $lang = null, ?string $intent = null): array
{
return $this->request('POST', '/admin/suggestion-misses/promote', body: array_filter([
'miss_ids' => $missIds,
'lang' => $lang,
'intent' => $intent,
], fn (mixed $value): bool => $value !== null));
}
// --- Suggestion sync/embeddings ------------------------------------------
/**
* @return array{job_id: string}
*/
public function syncSuggestions(): array
{
return $this->request('POST', '/admin/suggestions/sync-chroma');
}
/**
* @return array{status: string, result: mixed}
*/
public function getSuggestionSyncStatus(string $jobId): array
{
return $this->request('GET', "/admin/suggestions/sync-chroma/{$jobId}");
}
/**
* @return array<string, mixed>
*/
public function syncOneSuggestion(int|string $id): array
{
return $this->request('POST', "/admin/suggestions/{$id}/sync-chroma");
}
/**
* @return array<string, mixed>
*/
public function reloadSuggestionIndex(): array
{
return $this->request('POST', '/admin/suggestions/reload-index');
}
/**
* @return array<string, mixed>
*/
public function deleteSuggestionEmbedding(int|string $id): array
{
return $this->request('DELETE', "/admin/suggestions/{$id}/chroma");
}
// --- Request plumbing ------------------------------------------------
/**
@@ -183,7 +337,12 @@ class BnfexpressAdminClient
try {
$response = match ($method) {
'GET' => $pending->get($path, $query),
'DELETE' => $pending->delete($path, $query),
// DELETE with a body (e.g. batchDeleteSuggestions) must send it the same
// way POST/PATCH do — $query is never used as delete()'s $data here, that
// param means something else (a JSON body) than what its name implies.
'DELETE' => $body !== null
? $pending->withBody($rawBody, 'application/json')->delete($path)
: $pending->delete($path),
'POST' => $pending->withBody($rawBody, 'application/json')->post($path),
'PATCH' => $pending->withBody($rawBody, 'application/json')->patch($path),
default => throw new \InvalidArgumentException("Unsupported HTTP method [{$method}]."),
@@ -193,12 +352,34 @@ class BnfexpressAdminClient
}
if (! $response->successful()) {
throw new BnfexpressApiException(
(string) ($response->json('detail') ?? "bnfexpress request failed with status {$response->status()}."),
$response->status(),
);
throw new BnfexpressApiException($this->errorMessage($response), $response->status());
}
return (array) $response->json();
}
/**
* bnfexpress's {"detail": "..."} is usually a plain string, but FastAPI's
* own request-validation failures (422s) return `detail` as a list of
* {loc, msg, type} objects instead casting that straight to string
* produces the literal, useless "Array" (with a PHP warning). Handle
* both shapes.
*/
private function errorMessage(Response $response): string
{
$detail = $response->json('detail');
if (is_string($detail)) {
return $detail;
}
if (is_array($detail)) {
return implode(' ', array_map(
fn (mixed $item): string => is_array($item) ? (string) ($item['msg'] ?? json_encode($item)) : (string) $item,
$detail,
));
}
return "bnfexpress request failed with status {$response->status()}.";
}
}
@@ -74,6 +74,16 @@ test('a non-2xx response surfaces the gateway detail message', function () use (
(new BnfexpressAdminClient($config))->getFaq(999);
})->throws(BnfexpressApiException::class, 'FAQ not found.');
test('a FastAPI validation error (detail as a list of objects) is flattened into a readable message', function () use ($config) {
Http::fake(['bnfexpress.test/*' => Http::response([
'detail' => [
['type' => 'int_parsing', 'loc' => ['path', 'suggestion_id'], 'msg' => 'Input should be a valid integer, unable to parse string as an integer', 'input' => 'batch'],
],
], 422)]);
(new BnfexpressAdminClient($config))->getSuggestion('batch');
})->throws(BnfexpressApiException::class, 'Input should be a valid integer, unable to parse string as an integer');
test('deleteFaq signs a bodyless DELETE request', function () use ($config) {
Http::fake(['bnfexpress.test/*' => Http::response(['deleted' => true])]);
@@ -85,3 +95,114 @@ test('deleteFaq signs a bodyless DELETE request', function () use ($config) {
&& assertSignedCorrectly($request, 'DELETE', '/admin/faqs/5', '', $config['client_secret']);
});
});
test('createSuggestion sends text_display/lang/intent/weight/source', function () use ($config) {
Http::fake(['bnfexpress.test/*' => Http::response(['id' => 1], 201)]);
(new BnfexpressAdminClient($config))->createSuggestion('How do I charge?', 'en', 'charging', 5, 'manual');
$rawBody = json_encode([
'text_display' => 'How do I charge?',
'lang' => 'en',
'intent' => 'charging',
'weight' => 5,
'source' => 'manual',
]);
Http::assertSent(fn ($request) => $request->url() === 'https://bnfexpress.test/admin/suggestions'
&& $request->method() === 'POST'
&& $request->body() === $rawBody);
});
test('updateSuggestion only sends the given fields', function () use ($config) {
Http::fake(['bnfexpress.test/*' => Http::response(['id' => 1])]);
(new BnfexpressAdminClient($config))->updateSuggestion(1, weight: 10);
Http::assertSent(fn ($request) => $request->body() === json_encode(['weight' => 10]));
});
test('batchCreateSuggestions posts items and returns the created/skipped/trie_rebuilt result', function () use ($config) {
Http::fake(['bnfexpress.test/*' => Http::response(['created' => 2, 'skipped' => 1, 'trie_rebuilt' => true])]);
$result = (new BnfexpressAdminClient($config))->batchCreateSuggestions([
['text' => 'a', 'lang' => 'en'],
['text' => 'b', 'lang' => 'en', 'intent' => 'x'],
]);
expect($result)->toBe(['created' => 2, 'skipped' => 1, 'trie_rebuilt' => true]);
Http::assertSent(fn ($request) => $request->url() === 'https://bnfexpress.test/admin/suggestions/batch'
&& $request->method() === 'POST'
&& $request['items'] === [
['text' => 'a', 'lang' => 'en'],
['text' => 'b', 'lang' => 'en', 'intent' => 'x'],
]);
});
test('batchDeleteSuggestions sends a JSON body on DELETE and signs it', function () use ($config) {
Http::fake(['bnfexpress.test/*' => Http::response(['deleted' => 2, 'skipped' => 0])]);
$result = (new BnfexpressAdminClient($config))->batchDeleteSuggestions([1, 2]);
$rawBody = json_encode(['ids' => [1, 2]]);
expect($result)->toBe(['deleted' => 2, 'skipped' => 0]);
Http::assertSent(function ($request) use ($config, $rawBody) {
return $request->url() === 'https://bnfexpress.test/admin/suggestions/batch'
&& $request->method() === 'DELETE'
&& $request->body() === $rawBody
&& assertSignedCorrectly($request, 'DELETE', '/admin/suggestions/batch', $rawBody, $config['client_secret']);
});
});
test('listSuggestionMisses filters by was_used', function () use ($config) {
Http::fake(['bnfexpress.test/*' => Http::response([])]);
(new BnfexpressAdminClient($config))->listSuggestionMisses(wasUsed: false, limit: 25);
Http::assertSent(fn ($request) => $request->url() === 'https://bnfexpress.test/admin/suggestion-misses?was_used=0&limit=25'
|| $request->url() === 'https://bnfexpress.test/admin/suggestion-misses?was_used=&limit=25');
});
test('promoteSuggestionMisses posts miss_ids with an optional lang/intent override', function () use ($config) {
Http::fake(['bnfexpress.test/*' => Http::response(['created' => 1, 'skipped' => 0, 'trie_rebuilt' => true])]);
$result = (new BnfexpressAdminClient($config))->promoteSuggestionMisses([1, 2], lang: 'my');
expect($result)->toBe(['created' => 1, 'skipped' => 0, 'trie_rebuilt' => true]);
Http::assertSent(fn ($request) => $request->url() === 'https://bnfexpress.test/admin/suggestion-misses/promote'
&& $request->method() === 'POST'
&& $request->body() === json_encode(['miss_ids' => [1, 2], 'lang' => 'my']));
});
test('syncSuggestions triggers a sync-chroma job and returns the job id', function () use ($config) {
Http::fake(['bnfexpress.test/*' => Http::response(['job_id' => 'abc-123'], 202)]);
$result = (new BnfexpressAdminClient($config))->syncSuggestions();
expect($result)->toBe(['job_id' => 'abc-123']);
Http::assertSent(fn ($request) => $request->url() === 'https://bnfexpress.test/admin/suggestions/sync-chroma'
&& $request->method() === 'POST');
});
test('getSuggestionSyncStatus polls the job status endpoint', function () use ($config) {
Http::fake(['bnfexpress.test/*' => Http::response(['status' => 'finished', 'result' => ['synced' => 3]])]);
$result = (new BnfexpressAdminClient($config))->getSuggestionSyncStatus('abc-123');
expect($result)->toBe(['status' => 'finished', 'result' => ['synced' => 3]]);
Http::assertSent(fn ($request) => $request->url() === 'https://bnfexpress.test/admin/suggestions/sync-chroma/abc-123'
&& $request->method() === 'GET');
});
test('deleteSuggestionEmbedding signs a bodyless DELETE request', function () use ($config) {
Http::fake(['bnfexpress.test/*' => Http::response([])]);
(new BnfexpressAdminClient($config))->deleteSuggestionEmbedding(5);
Http::assertSent(function ($request) use ($config) {
return $request->url() === 'https://bnfexpress.test/admin/suggestions/5/chroma'
&& $request->method() === 'DELETE'
&& assertSignedCorrectly($request, 'DELETE', '/admin/suggestions/5/chroma', '', $config['client_secret']);
});
});