Skip to content

Commit 880f6cc

Browse files
KlimTodrikdjklim87
andauthored
Feat: Add support for JSON endpoints in Conversational Search (#685)
ref: #683 Co-authored-by: djklim87 <klim@manticoresearch.com>
1 parent 995c6b1 commit 880f6cc

4 files changed

Lines changed: 302 additions & 44 deletions

File tree

composer.lock

Lines changed: 21 additions & 21 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Plugin/ConversationalSearch/Payload.php

Lines changed: 138 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Manticoresearch\Buddy\Base\Plugin\ConversationalSearch;
1313

1414
use Manticoresearch\Buddy\Core\Error\QueryParseError;
15+
use Manticoresearch\Buddy\Core\ManticoreSearch\Endpoint as ManticoreEndpoint;
1516
use Manticoresearch\Buddy\Core\Network\Request;
1617
use Manticoresearch\Buddy\Core\Plugin\BasePayload;
1718

@@ -24,6 +25,9 @@ final class Payload extends BasePayload {
2425
public const string ACTION_DESCRIBE_MODEL = 'describe_model';
2526
public const string ACTION_DROP_MODEL = 'drop_model';
2627
public const string ACTION_CONVERSATION = 'conversation';
28+
private const array CONVERSATION_JSON_FIELDS = [
29+
'query', 'table', 'model_name', 'conversation_uuid', 'vector_field', 'fields',
30+
];
2731

2832
/** @var string */
2933
public string $action;
@@ -42,6 +46,10 @@ final class Payload extends BasePayload {
4246
* @return bool
4347
*/
4448
public static function hasMatch(Request $request): bool {
49+
if (self::matchesJson($request)) {
50+
return true;
51+
}
52+
4553
// Check SQL patterns first
4654
if (self::matchesSQL($request)) {
4755
return true;
@@ -50,6 +58,22 @@ public static function hasMatch(Request $request): bool {
5058
return false;
5159
}
5260

61+
/**
62+
* Check if HTTP JSON /search request contains chat payload
63+
*
64+
* @param Request $request
65+
*
66+
* @return bool
67+
*/
68+
private static function matchesJson(Request $request): bool {
69+
if ($request->endpointBundle !== ManticoreEndpoint::Search) {
70+
return false;
71+
}
72+
73+
$payload = simdjson_decode($request->payload, true);
74+
return is_array($payload) && isset($payload['chat']) && is_array($payload['chat']);
75+
}
76+
5377
/**
5478
* Check if SQL query matches chat patterns
5579
*
@@ -94,15 +118,125 @@ private static function matchesSQL(Request $request): bool {
94118
* @throws QueryParseError
95119
*/
96120
public static function fromRequest(Request $request): static {
97-
$payload = new static();
98-
$payload->query = $request->payload;
121+
return match ($request->endpointBundle) {
122+
ManticoreEndpoint::Search => static::fromJsonRequest($request),
123+
default => static::fromSqlRequest($request),
124+
};
125+
}
126+
127+
/**
128+
* @param Request $request
129+
*
130+
* @return static
131+
* @throws QueryParseError
132+
*/
133+
protected static function fromJsonRequest(Request $request): static {
134+
$self = new static();
135+
$self->query = $request->payload;
136+
$payload = $self->decodeJsonBody($request);
137+
if (!isset($payload['chat']) || !is_array($payload['chat'])) {
138+
throw QueryParseError::create('HTTP JSON body must contain chat object');
139+
}
140+
141+
$self->parseJsonConversation($payload['chat']);
142+
return $self;
143+
}
144+
145+
/**
146+
* @param Request $request
147+
*
148+
* @return static
149+
* @throws QueryParseError
150+
*/
151+
protected static function fromSqlRequest(Request $request): static {
152+
$self = new static();
153+
$self->query = $request->payload;
154+
$self->parseSQLRequest($request);
155+
return $self;
156+
}
157+
158+
/**
159+
* @param Request $request
160+
*
161+
* @return array<string, mixed>
162+
* @throws QueryParseError
163+
*/
164+
private function decodeJsonBody(Request $request): array {
165+
if (trim($request->payload) === '') {
166+
throw QueryParseError::create('HTTP JSON body is required');
167+
}
99168

100-
// Parse SQL request only (HTTP API not supported)
101-
$payload->parseSQLRequest($request);
169+
$payload = simdjson_decode($request->payload, true);
170+
if (!is_array($payload)) {
171+
throw QueryParseError::create('HTTP JSON body must be an object');
172+
}
102173

103174
return $payload;
104175
}
105176

177+
/**
178+
* @param array<string, mixed> $payload
179+
*
180+
* @return void
181+
* @throws QueryParseError
182+
*/
183+
private function parseJsonConversation(array $payload): void {
184+
$this->action = self::ACTION_CONVERSATION;
185+
$hasVectorField = false;
186+
$hasFields = false;
187+
188+
foreach ($payload as $field => $value) {
189+
$this->parseJsonConversationField($field, $value, $hasVectorField, $hasFields);
190+
}
191+
192+
if ($hasVectorField && $hasFields) {
193+
throw QueryParseError::create('Use either vector_field or fields, not both');
194+
}
195+
196+
$this->validateRequiredJsonConversationParams();
197+
}
198+
199+
/**
200+
* @throws QueryParseError
201+
*/
202+
private function parseJsonConversationField(
203+
string $field,
204+
mixed $value,
205+
bool &$hasVectorField,
206+
bool &$hasFields
207+
): void {
208+
if (!in_array($field, self::CONVERSATION_JSON_FIELDS, true)) {
209+
throw QueryParseError::create("Unknown chat JSON field: $field");
210+
}
211+
212+
if (!is_string($value)) {
213+
throw QueryParseError::create("$field must be a string");
214+
}
215+
216+
if ($field === 'vector_field') {
217+
$hasVectorField = true;
218+
$this->params['fields'] = $value;
219+
return;
220+
}
221+
222+
if ($field === 'fields') {
223+
$hasFields = true;
224+
}
225+
226+
$this->params[$field] = $value;
227+
}
228+
229+
/**
230+
* @throws QueryParseError
231+
*/
232+
private function validateRequiredJsonConversationParams(): void {
233+
foreach (['query', 'table', 'model_name'] as $field) {
234+
if (!isset($this->params[$field]) || trim($this->params[$field]) === '') {
235+
throw QueryParseError::create("$field must be a non-empty string");
236+
}
237+
}
238+
}
239+
106240

107241
/**
108242
* Parse SQL request

src/Plugin/ConversationalSearch/README.md

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@ Manticore Buddy. It searches an existing vectorized table, builds context from
55
matched documents, and asks an LLM to answer using that context and the current
66
conversation history.
77

8-
Supported commands:
8+
Supported SQL commands:
99

1010
- `CREATE CHAT MODEL`
1111
- `SHOW CHAT MODELS`
1212
- `DESCRIBE CHAT MODEL`
1313
- `DROP CHAT MODEL`
1414
- `CALL CHAT`
1515

16+
Conversation calls are also available through the HTTP JSON `/search` endpoint.
17+
Chat model management remains SQL-only.
18+
1619
## How It Works
1720

1821
At query time `CALL CHAT` does this:
@@ -204,8 +207,53 @@ Arguments are positional only:
204207
| 4 | `conversation_uuid` | No | Existing conversation id, or empty string |
205208
| 5 | `fields` / vector field | No | `FLOAT_VECTOR` field used in `knn(...)` |
206209

207-
The fifth argument is stored internally as `fields` for compatibility with the
208-
current parser, but it must be a single vector field name.
210+
The table argument must be a plain table identifier, optionally qualified as
211+
`database.table`. The vector field argument must be a plain field identifier.
212+
213+
## HTTP JSON Syntax
214+
215+
Conversational Search also supports named-field HTTP JSON requests through the
216+
standard `/search` endpoint.
217+
218+
Start or continue a conversation:
219+
220+
```bash
221+
curl -s -X POST http://localhost:9308/search \
222+
-H 'Content-Type: application/json' \
223+
-d '{
224+
"chat": {
225+
"query": "How is vector search different from full-text search?",
226+
"table": "docs",
227+
"model_name": "assistant",
228+
"conversation_uuid": "search-demo-1",
229+
"vector_field": "embedding"
230+
}
231+
}'
232+
```
233+
234+
Required fields:
235+
236+
| Field | Description |
237+
|---|---|
238+
| `query` | User question |
239+
| `table` | Table to search |
240+
| `model_name` | chat model name |
241+
242+
Optional fields:
243+
244+
| Field | Description |
245+
|---|---|
246+
| `conversation_uuid` | Existing conversation id. If omitted or empty, Buddy creates a new one |
247+
| `vector_field` | `FLOAT_VECTOR` field used in `knn(...)` |
248+
249+
`vector_field` is the HTTP JSON name for the fifth SQL `CALL CHAT` argument.
250+
The legacy JSON field name `fields` is accepted as an alias, but requests must
251+
not include both `vector_field` and `fields`.
252+
253+
HTTP JSON conversation responses use the same logical columns as `CALL CHAT`:
254+
`conversation_uuid`, `user_query`, `search_query`, `response`,
255+
`response_with_refs`, and `sources`. `sources` is currently returned as a JSON
256+
string containing the retrieved source rows.
209257

210258
The table argument must be a plain table identifier, optionally qualified as
211259
`database.table`. The vector field argument must be a plain field identifier.

0 commit comments

Comments
 (0)