diff --git a/composer.json b/composer.json index 9651b02..98892f9 100644 --- a/composer.json +++ b/composer.json @@ -54,7 +54,7 @@ "test:lint": "@php vendor/bin/pint --config https://raw.githubusercontent.com/DIJ-digital/pint-config/main/pint.json", "test:unit": "pest", "test:types": "phpstan", - "test:refactor": "rector --dry-run", + "test:refactor": "rector", "test": [ "@test:lint", "@test:type-coverage", diff --git a/src/Contracts/TransporterInterface.php b/src/Contracts/TransporterInterface.php index 8234ade..558497a 100644 --- a/src/Contracts/TransporterInterface.php +++ b/src/Contracts/TransporterInterface.php @@ -9,34 +9,34 @@ interface TransporterInterface { /** - * @param array $options + * @param array $options */ public function request(string $method, string $uri, array $options = []): ResponseInterface; /** - * @param array $options + * @param array $options */ public function get(string $uri, array $options = []): ResponseInterface; /** - * @param array $options + * @param array $options */ public function post(string $uri, array $options = []): ResponseInterface; /** - * @param array $data - * @param array $options + * @param array $data + * @param array $options */ public function postJson(string $uri, array $data = [], array $options = []): ResponseInterface; /** - * @param array $options + * @param array $options */ public function delete(string $uri, array $options = []): ResponseInterface; /** - * @param array $data - * @param array $options + * @param array $data + * @param array $options */ public function patchJson(string $uri, array $data = [], array $options = []): ResponseInterface; } diff --git a/src/Langfuse.php b/src/Langfuse.php index ebe96f9..2bf2d2f 100644 --- a/src/Langfuse.php +++ b/src/Langfuse.php @@ -12,8 +12,7 @@ public function __construct( private readonly TransporterInterface $transporter, private readonly string $environment = 'default', private readonly string $label = 'latest', - ) { - } + ) {} public function prompt(): Prompt { @@ -37,4 +36,11 @@ public function score(): Score transporter: $this->transporter, ); } + + public function scoreConfig(): ScoreConfig + { + return new ScoreConfig( + transporter: $this->transporter, + ); + } } diff --git a/src/Responses/ScoreConfigListResponse.php b/src/Responses/ScoreConfigListResponse.php new file mode 100644 index 0000000..44bf47a --- /dev/null +++ b/src/Responses/ScoreConfigListResponse.php @@ -0,0 +1,44 @@ + $data + */ + public function __construct( + public array $data, + public MetaData $meta, + ) {} + + /** + * @param array{ + * data: array|null, + * description: string|null, + * projectId: string, + * createdAt: string, + * updatedAt: string + * }>, + * meta: array{page: int, limit: int, totalPages: int, totalItems: int} + * } $data + */ + public static function fromArray(array $data): self + { + return new self( + data: array_map(fn (array $item): ScoreConfigResponse => ScoreConfigResponse::fromArray($item), $data['data']), + meta: MetaData::fromArray($data['meta']), + ); + } +} diff --git a/src/Responses/ScoreConfigResponse.php b/src/Responses/ScoreConfigResponse.php new file mode 100644 index 0000000..f59058b --- /dev/null +++ b/src/Responses/ScoreConfigResponse.php @@ -0,0 +1,57 @@ +|null $categories + */ + public function __construct( + public string $id, + public string $name, + public string $dataType, + public bool $isArchived, + public ?float $minValue, + public ?float $maxValue, + public ?array $categories, + public ?string $description, + public string $projectId, + public string $createdAt, + public string $updatedAt, + ) {} + + /** + * @param array{ + * id: string, + * name: string, + * dataType: string, + * isArchived: bool, + * minValue: float|null, + * maxValue: float|null, + * categories: array|null, + * description: string|null, + * projectId: string, + * createdAt: string, + * updatedAt: string + * } $data + */ + public static function fromArray(array $data): self + { + return new self( + id: $data['id'], + name: $data['name'], + dataType: $data['dataType'], + isArchived: $data['isArchived'], + minValue: $data['minValue'], + maxValue: $data['maxValue'], + categories: $data['categories'], + description: $data['description'], + projectId: $data['projectId'], + createdAt: $data['createdAt'], + updatedAt: $data['updatedAt'], + ); + } +} diff --git a/src/ScoreConfig.php b/src/ScoreConfig.php new file mode 100644 index 0000000..1369081 --- /dev/null +++ b/src/ScoreConfig.php @@ -0,0 +1,124 @@ +transporter->get( + uri: sprintf('/api/public/score-configs/%s', urlencode($configId)), + ); + + /** @var array{ + * id: string, + * name: string, + * dataType: string, + * isArchived: bool, + * minValue: float|null, + * maxValue: float|null, + * categories: array|null, + * description: string|null, + * projectId: string, + * createdAt: string, + * updatedAt: string + * } $data + */ + $data = json_decode($response->getBody()->getContents(), true, flags: JSON_THROW_ON_ERROR); + + return ScoreConfigResponse::fromArray($data); + } + + /** + * @throws JsonException + */ + public function list( + ?int $page = null, + ?int $limit = null, + ): ScoreConfigListResponse { + $response = $this->transporter->get( + uri: '/api/public/score-configs', + options: ['query' => array_filter([ + 'page' => $page, + 'limit' => $limit, + ])] + ); + + /** @var array{ + * data: array|null, + * description: string|null, + * projectId: string, + * createdAt: string, + * updatedAt: string + * }>, + * meta: array{page: int, limit: int, totalPages: int, totalItems: int} + * } $data + */ + $data = json_decode($response->getBody()->getContents(), true, flags: JSON_THROW_ON_ERROR); + + return ScoreConfigListResponse::fromArray($data); + } + + /** + * @param array|null $categories + * + * @throws JsonException + */ + public function create( + string $name, + string $dataType, + ?float $minValue = null, + ?float $maxValue = null, + ?array $categories = null, + ?string $description = null, + ): ScoreConfigResponse { + $response = $this->transporter->postJson( + uri: '/api/public/score-configs', + data: array_filter([ + 'name' => $name, + 'dataType' => $dataType, + 'minValue' => $minValue, + 'maxValue' => $maxValue, + 'categories' => $categories, + 'description' => $description, + ], fn ($v) => $v !== null), + ); + + /** @var array{ + * id: string, + * name: string, + * dataType: string, + * isArchived: bool, + * minValue: float|null, + * maxValue: float|null, + * categories: array|null, + * description: string|null, + * projectId: string, + * createdAt: string, + * updatedAt: string + * } $data + */ + $data = json_decode($response->getBody()->getContents(), true, flags: JSON_THROW_ON_ERROR); + + return ScoreConfigResponse::fromArray($data); + } +} diff --git a/src/Testing/Responses/GetScoreConfigListResponse.php b/src/Testing/Responses/GetScoreConfigListResponse.php new file mode 100644 index 0000000..d76b799 --- /dev/null +++ b/src/Testing/Responses/GetScoreConfigListResponse.php @@ -0,0 +1,65 @@ +|string> $headers + */ + public function __construct(int $status = 200, array $headers = [], string $version = '1.1', ?string $reason = null) + { + parent::__construct($status, $headers, (string) json_encode($this->payload()), $version, $reason); + } + + /** + * @return array + */ + public function payload(): array + { + return [ + 'data' => [ + [ + 'id' => 'config-abc123', + 'name' => 'quality-score', + 'dataType' => 'NUMERIC', + 'isArchived' => false, + 'minValue' => 0.0, + 'maxValue' => 1.0, + 'categories' => null, + 'description' => 'Quality score for LLM outputs', + 'projectId' => 'proj-abc123', + 'createdAt' => '2025-01-22T10:00:00.000Z', + 'updatedAt' => '2025-01-22T10:00:00.000Z', + ], + [ + 'id' => 'config-def456', + 'name' => 'sentiment', + 'dataType' => 'CATEGORICAL', + 'isArchived' => false, + 'minValue' => null, + 'maxValue' => null, + 'categories' => [ + ['value' => 1.0, 'label' => 'positive'], + ['value' => 0.0, 'label' => 'neutral'], + ['value' => -1.0, 'label' => 'negative'], + ], + 'description' => 'Sentiment classification', + 'projectId' => 'proj-abc123', + 'createdAt' => '2025-01-22T09:00:00.000Z', + 'updatedAt' => '2025-01-22T09:00:00.000Z', + ], + ], + 'meta' => [ + 'page' => 1, + 'limit' => 10, + 'totalPages' => 1, + 'totalItems' => 2, + ], + ]; + } +} diff --git a/src/Testing/Responses/GetScoreConfigResponse.php b/src/Testing/Responses/GetScoreConfigResponse.php new file mode 100644 index 0000000..91a78ba --- /dev/null +++ b/src/Testing/Responses/GetScoreConfigResponse.php @@ -0,0 +1,40 @@ +|string> $headers + * @param array $data + */ + public function __construct(int $status = 200, array $headers = [], string $version = '1.1', ?string $reason = null, array $data = []) + { + parent::__construct($status, $headers, (string) json_encode($this->payload($data)), $version, $reason); + } + + /** + * @param array $data + * @return array + */ + public function payload(array $data = []): array + { + return array_merge([ + 'id' => 'config-abc123', + 'name' => 'quality-score', + 'dataType' => 'NUMERIC', + 'isArchived' => false, + 'minValue' => 0.0, + 'maxValue' => 1.0, + 'categories' => null, + 'description' => 'Quality score for LLM outputs', + 'projectId' => 'proj-abc123', + 'createdAt' => '2025-01-22T10:00:00.000Z', + 'updatedAt' => '2025-01-22T10:00:00.000Z', + ], $data); + } +} diff --git a/src/Testing/Responses/PostScoreConfigResponse.php b/src/Testing/Responses/PostScoreConfigResponse.php new file mode 100644 index 0000000..5932b90 --- /dev/null +++ b/src/Testing/Responses/PostScoreConfigResponse.php @@ -0,0 +1,40 @@ +|string> $headers + * @param array $data + */ + public function __construct(int $status = 200, array $headers = [], string $version = '1.1', ?string $reason = null, array $data = []) + { + parent::__construct($status, $headers, (string) json_encode($this->payload($data)), $version, $reason); + } + + /** + * @param array $data + * @return array + */ + public function payload(array $data = []): array + { + return array_merge([ + 'id' => 'config-new123', + 'name' => 'new-score-config', + 'dataType' => 'NUMERIC', + 'isArchived' => false, + 'minValue' => 0.0, + 'maxValue' => 10.0, + 'categories' => null, + 'description' => 'A new score configuration', + 'projectId' => 'proj-abc123', + 'createdAt' => '2025-01-22T11:00:00.000Z', + 'updatedAt' => '2025-01-22T11:00:00.000Z', + ], $data); + } +} diff --git a/src/Transporters/HttpTransporter.php b/src/Transporters/HttpTransporter.php index 319cf49..a2b734b 100644 --- a/src/Transporters/HttpTransporter.php +++ b/src/Transporters/HttpTransporter.php @@ -21,8 +21,7 @@ class HttpTransporter implements TransporterInterface { public function __construct( public readonly ClientInterface $client - ) { - } + ) {} /** * @throws BadRequestException @@ -45,7 +44,7 @@ public function request(string $method, string $uri, array $options = []): Respo } /** - * @param array $options + * @param array $options * * @throws BadRequestException * @throws ForbiddenException @@ -61,7 +60,7 @@ public function get(string $uri, array $options = []): ResponseInterface } /** - * @param array $options + * @param array $options * * @throws BadRequestException * @throws ForbiddenException @@ -82,7 +81,7 @@ public function postJson(string $uri, array $data = [], array $options = []): Re } /** - * @param array $options + * @param array $options * * @throws BadRequestException * @throws ForbiddenException @@ -98,8 +97,8 @@ public function delete(string $uri, array $options = []): ResponseInterface } /** - * @param array $data - * @param array $options + * @param array $data + * @param array $options * * @throws BadRequestException * @throws ForbiddenException diff --git a/tests/Feature/ScoreConfigTest.php b/tests/Feature/ScoreConfigTest.php new file mode 100644 index 0000000..c2338f1 --- /dev/null +++ b/tests/Feature/ScoreConfigTest.php @@ -0,0 +1,114 @@ + $handlerStack]); + + $config = (new Langfuse(new HttpTransporter($client))) + ->scoreConfig() + ->get('config-abc123'); + + expect($config)->toBeInstanceOf(ScoreConfigResponse::class) + ->and($config->id)->toBe('config-abc123') + ->and($config->name)->toBe('quality-score') + ->and($config->dataType)->toBe('NUMERIC') + ->and($config->isArchived)->toBe(false) + ->and($config->minValue)->toBe(0.0) + ->and($config->maxValue)->toBe(1.0) + ->and($config->description)->toBe('Quality score for LLM outputs'); +}); + +it('can list score configs', function (): void { + $mock = new MockHandler([ + new GetScoreConfigListResponse, + ]); + + $handlerStack = HandlerStack::create($mock); + $client = new Client(['handler' => $handlerStack]); + + $configs = (new Langfuse(new HttpTransporter($client))) + ->scoreConfig() + ->list(); + + expect($configs)->toBeInstanceOf(ScoreConfigListResponse::class) + ->and($configs->data)->toBeArray() + ->and($configs->data)->toHaveCount(2) + ->and($configs->data[0])->toBeInstanceOf(ScoreConfigResponse::class) + ->and($configs->data[0]->name)->toBe('quality-score') + ->and($configs->data[0]->dataType)->toBe('NUMERIC') + ->and($configs->data[1]->name)->toBe('sentiment') + ->and($configs->data[1]->dataType)->toBe('CATEGORICAL') + ->and($configs->meta)->toBeInstanceOf(MetaData::class) + ->and($configs->meta->totalItems)->toBe(2); +}); + +it('can create a score config', function (): void { + $mock = new MockHandler([ + new PostScoreConfigResponse, + ]); + + $handlerStack = HandlerStack::create($mock); + $client = new Client(['handler' => $handlerStack]); + + $config = (new Langfuse(new HttpTransporter($client))) + ->scoreConfig() + ->create( + name: 'new-score-config', + dataType: 'NUMERIC', + minValue: 0.0, + maxValue: 10.0, + description: 'A new score configuration', + ); + + expect($config)->toBeInstanceOf(ScoreConfigResponse::class) + ->and($config->id)->toBe('config-new123') + ->and($config->name)->toBe('new-score-config') + ->and($config->dataType)->toBe('NUMERIC') + ->and($config->minValue)->toBe(0.0) + ->and($config->maxValue)->toBe(10.0); +}); + +it('can get score config with categories', function (): void { + $mock = new MockHandler([ + new GetScoreConfigListResponse, + ]); + + $handlerStack = HandlerStack::create($mock); + $client = new Client(['handler' => $handlerStack]); + + $configs = (new Langfuse(new HttpTransporter($client))) + ->scoreConfig() + ->list(); + + $categoricalConfig = $configs->data[1]; + + expect($categoricalConfig->categories)->toBeArray() + ->and($categoricalConfig->categories)->toHaveCount(3) + ->and($categoricalConfig->categories)->not->toBeNull(); + + $categories = $categoricalConfig->categories; + assert($categories !== null); + + /** @var array{value: float, label: string} $category */ + $category = $categories[0]; + expect($category['value'])->toEqual(1.0) + ->and($category['label'])->toBe('positive'); +});