Skip to content

Commit dc2f8c9

Browse files
KlimTodrikdjklim87
andauthored
Feat: Add support for JSON endpoints in Conversational Search (#690)
* Feat: Add support for JSON endpoints in Conversational Search ref: #683 --------- Co-authored-by: djklim87 <klim@manticoresearch.com>
1 parent 84ece03 commit dc2f8c9

8 files changed

Lines changed: 387 additions & 77 deletions

File tree

composer.lock

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

src/Plugin/ConversationalSearch/Handler.php

Lines changed: 33 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ private static function createModel(
102102
): TaskResult {
103103
/** @var array{identifier: string, model: string, description?: string,
104104
* api_key?: string, base_url?: string, timeout?: string|int, retrieval_limit?: string|int,
105-
* max_document_length?: string|int} $config
105+
* max_document_length?: string|int, custom_prompt?: string} $config
106106
*/
107107
$config = $payload->params;
108108
$createConfig = (new ModelConfigValidator())->validate($config);
@@ -286,8 +286,7 @@ private static function handleConversation(
286286
);
287287
}
288288

289-
$responseWithRefs = $response['content'];
290-
$responseText = self::stripReferences($responseWithRefs);
289+
$responseText = $response['content'];
291290
$tokensUsed = $response['metadata']['tokens_used'];
292291

293292
$turn = new ConversationTurn(
@@ -307,14 +306,12 @@ private static function handleConversation(
307306
'user_query' => $request->query,
308307
'search_query' => $queries['search_query'],
309308
'response' => $responseText,
310-
'response_with_refs' => $responseWithRefs,
311309
'sources' => json_encode($searchResults),
312310
]
313311
)->column('conversation_uuid', Column::String)
314312
->column('user_query', Column::String)
315313
->column('search_query', Column::String)
316314
->column('response', Column::String)
317-
->column('response_with_refs', Column::String)
318315
->column('sources', Column::String);
319316
}
320317

@@ -532,16 +529,6 @@ private static function logPreprocessingResults(
532529
Buddy::debugv("Chat: └─ Exclude query: '{$queries['exclude_query']}'");
533530
}
534531

535-
/**
536-
* @return string
537-
*/
538-
private static function stripReferences(string $responseWithRefs): string {
539-
$response = preg_replace('/\s*\[ref:[^\]\s]+\]/', '', $responseWithRefs);
540-
$response = preg_replace('/[ ]+([.,;:!?])/', '$1', (string)$response);
541-
542-
return trim((string)$response);
543-
}
544-
545532
/**
546533
* @param array<int, array<string, mixed>> $searchResults
547534
* @param string $contentFields
@@ -616,7 +603,13 @@ private static function generateResponse(
616603
): array {
617604
$provider->configure($model);
618605

619-
$prompt = self::buildPrompt($query, $context, $history->payload());
606+
$customPrompt = $model['settings']['custom_prompt'] ?? null;
607+
$prompt = self::buildPrompt(
608+
$query,
609+
$context,
610+
$history->payload(),
611+
is_string($customPrompt) ? $customPrompt : ''
612+
);
620613
$settings = self::getLlmRequestOptions();
621614

622615
return $provider->generateResponse($prompt, $settings);
@@ -640,26 +633,31 @@ private static function getLlmRequestOptions(): array {
640633
*
641634
* @throws JsonException
642635
*/
643-
private static function buildPrompt(string $query, string $context, array $history): string {
636+
private static function buildPrompt(
637+
string $query,
638+
string $context,
639+
array $history,
640+
string $customPrompt = ''
641+
): string {
644642
$historyJson = (string)json_encode($history, JSON_THROW_ON_ERROR);
645-
646-
return "system:\n"
647-
. "You are a context-only answer writer.\n\n"
648-
. 'Answer using only the provided context. Do not use outside knowledge, memory, assumptions, '
649-
. "or unsupported facts.\n"
650-
. 'Keep the answer concise and under ' . self::RESPONSE_MAX_TOKENS . " tokens.\n"
651-
. "Citation rule:\n\n"
652-
. "First write the answer with no citations at all.\n"
653-
. "After the answer is finished, append the reference context ID (context[].id) once.\n"
654-
. "If id reference was used, it must be exactly: [ref:<id>]\n"
655-
. "Never put a reference ID after individual sentences.\n"
656-
. "Never repeat the same reference ID.\n\n"
657-
. "If the context is insufficient, answer exactly:\n"
658-
. "I don’t have enough information in the provided context to answer.\n\n"
659-
. "user:\n"
660-
. "Query: $query\n\n"
661-
. "History:\n```json\n$historyJson\n```\n"
662-
. "Context:\n```json\n$context\n```";
643+
$prompt = trim($customPrompt) !== ''
644+
? trim($customPrompt)
645+
: 'Respond conversationally. Response should be based ONLY on the provided history and context sections' .
646+
"(IMPORTANT !!! You can't use your own knowledge to add anything that isn't mentioned there). " .
647+
'Do not exceed the response token limit (' .
648+
self::RESPONSE_MAX_TOKENS .
649+
'), and end the answer cleanly before reaching it.';
650+
651+
return $prompt . ' ' .
652+
'<main>' .
653+
"<history>\n" .
654+
"```json\n" .
655+
$historyJson .
656+
"\n```\n" .
657+
"</history>\n" .
658+
"<context>$context</context>\n" .
659+
"<query>$query</query>\n" .
660+
'</main>';
663661
}
664662

665663
/**

src/Plugin/ConversationalSearch/ModelConfigValidator.php

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
use Manticoresearch\Buddy\Core\Error\QueryParseError;
1515

1616
final class ModelConfigValidator {
17+
private const int MAX_CUSTOM_PROMPT_BYTES = 32768;
18+
1719
/**
1820
* Supported flat fields for CREATE CHAT MODEL.
1921
*
@@ -27,16 +29,17 @@ final class ModelConfigValidator {
2729
'timeout',
2830
'retrieval_limit',
2931
'max_document_length',
32+
'custom_prompt',
3033
];
3134

3235
/**
3336
* @param array{identifier:string, model: string, description?: string,
3437
* api_key?: string, base_url?: string, timeout?: string|int, retrieval_limit?: string|int,
35-
* max_document_length?: string|int} $config
38+
* max_document_length?: string|int, custom_prompt?: string} $config
3639
*
3740
* @return array{name: string, model: string, description?: string,
3841
* api_key?: string, base_url?: string, timeout?: string|int, retrieval_limit?: string|int,
39-
* max_document_length?: string|int}
42+
* max_document_length?: string|int, custom_prompt?: string}
4043
* @throws QueryParseError
4144
*/
4245
public function validate(array $config): array {
@@ -46,6 +49,7 @@ public function validate(array $config): array {
4649
$this->validateTimeout($config);
4750
$this->validateRetrievalLimit($config);
4851
$this->validateMaxDocumentLength($config);
52+
$this->validateCustomPrompt($config);
4953

5054
$createConfig = ['name' => $config['identifier']];
5155
foreach (self::MODEL_FIELDS as $field) {
@@ -58,7 +62,7 @@ public function validate(array $config): array {
5862

5963
/** @var array{name: string, model: string, description?: string,
6064
* api_key?: string, base_url?: string, timeout?: string|int, retrieval_limit?: string|int,
61-
* max_document_length?: string|int} $createConfig
65+
* max_document_length?: string|int, custom_prompt?: string} $createConfig
6266
*/
6367
return $createConfig;
6468
}
@@ -85,7 +89,7 @@ private function validateSupportedFields(array $config): void {
8589
/**
8690
* @param array{identifier:string, model: string, description?: string,
8791
* api_key?: string, base_url?: string, timeout?: string|int, retrieval_limit?: string|int,
88-
* max_document_length?: string|int} $config
92+
* max_document_length?: string|int, custom_prompt?: string} $config
8993
*
9094
* @return void
9195
* @throws QueryParseError
@@ -99,7 +103,7 @@ private function validateRequiredFields(array $config): void {
99103
/**
100104
* @param array{identifier:string, model: string, description?: string,
101105
* api_key?: string, base_url?: string, timeout?: string|int, retrieval_limit?: string|int,
102-
* max_document_length?: string|int} $config
106+
* max_document_length?: string|int, custom_prompt?: string} $config
103107
*
104108
* @return void
105109
* @throws QueryParseError
@@ -115,7 +119,7 @@ private function validateModelId(array $config): void {
115119
/**
116120
* @param array{identifier:string, model: string, description?: string,
117121
* api_key?: string, base_url?: string, timeout?: string|int, retrieval_limit?: string|int,
118-
* max_document_length?: string|int} $config
122+
* max_document_length?: string|int, custom_prompt?: string} $config
119123
*
120124
* @return void
121125
* @throws QueryParseError
@@ -142,7 +146,7 @@ private function validateTimeout(array $config): void {
142146
/**
143147
* @param array{identifier:string, model: string, description?: string,
144148
* api_key?: string, base_url?: string, timeout?: string|int, retrieval_limit?: string|int,
145-
* max_document_length?: string|int} $config
149+
* max_document_length?: string|int, custom_prompt?: string} $config
146150
*
147151
* @return void
148152
* @throws QueryParseError
@@ -169,7 +173,7 @@ private function validateRetrievalLimit(array $config): void {
169173
/**
170174
* @param array{identifier:string, model: string, description?: string,
171175
* api_key?: string, base_url?: string, timeout?: string|int, retrieval_limit?: string|int,
172-
* max_document_length?: string|int} $config
176+
* max_document_length?: string|int, custom_prompt?: string} $config
173177
*
174178
* @return void
175179
* @throws QueryParseError
@@ -198,4 +202,29 @@ private function validateMaxDocumentLength(array $config): void {
198202
);
199203
}
200204
}
205+
206+
/**
207+
* @param array{identifier:string, model: string, description?: string,
208+
* api_key?: string, base_url?: string, timeout?: string|int, retrieval_limit?: string|int,
209+
* max_document_length?: string|int, custom_prompt?: string} $config
210+
*
211+
* @return void
212+
* @throws QueryParseError
213+
*/
214+
private function validateCustomPrompt(array $config): void {
215+
if (!isset($config['custom_prompt'])) {
216+
return;
217+
}
218+
219+
$customPrompt = $config['custom_prompt'];
220+
if (trim($customPrompt) === '') {
221+
throw QueryParseError::create('custom_prompt must be a non-empty string');
222+
}
223+
224+
if (strlen($customPrompt) > self::MAX_CUSTOM_PROMPT_BYTES) {
225+
throw QueryParseError::create(
226+
'custom_prompt must be at most ' . self::MAX_CUSTOM_PROMPT_BYTES . ' bytes'
227+
);
228+
}
229+
}
201230
}

src/Plugin/ConversationalSearch/ModelManager.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ class ModelManager {
3232
* @param HTTPClient $client
3333
* @param array{name: string, model: string, description?: string, api_key?: string,
3434
* base_url?: string, timeout?: string|int, retrieval_limit?: string|int,
35-
* max_document_length?: string|int} $config
35+
* max_document_length?: string|int, custom_prompt?: string} $config
3636
*
3737
* @return string Model name
3838
* @throws ManticoreSearchClientError|ManticoreSearchResponseError|QueryParseError

src/Plugin/ConversationalSearch/README.md

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ Model with prompt, transport options, and retrieval settings:
101101
```sql
102102
CREATE CHAT MODEL support_assistant (
103103
model='openai:gpt-4o-mini',
104+
custom_prompt='You are a support assistant. Answer using the retrieved context and mention source ids.',
104105
api_key='your-provider-api-key',
105106
base_url='http://host.docker.internal:8787/v1',
106107
timeout=60,
@@ -115,6 +116,7 @@ Common options:
115116
|---|---:|---|
116117
| `model` | Yes | LLM model id in `provider:model` format |
117118
| `description` | No | Stored description |
119+
| `custom_prompt` | No | Prompt instructions used to build the answer from the retrieved context and conversation history; if set, it must be non-empty and at most 32768 bytes |
118120
| `api_key` | No | Provider API key passed to the `llm` extension |
119121
| `base_url` | No | Provider or proxy base URL |
120122
| `timeout` | No | LLM request timeout, `1..65536` |
@@ -124,6 +126,12 @@ Common options:
124126
Model names in `CREATE CHAT MODEL` may contain letters, numbers, and
125127
underscores only.
126128

129+
`custom_prompt` is optional. If it is omitted, Buddy uses its default answer
130+
prompt. If it is set, it must contain non-whitespace text and must not exceed
131+
32768 bytes. To make the LLM return source citations, include that instruction
132+
in `custom_prompt`, for example: `Cite sources as [ref:<id>] using source row
133+
ids from the context.`
134+
127135
`model` is validated as `provider:model`, for example:
128136

129137
```sql
@@ -251,9 +259,8 @@ The legacy JSON field name `fields` is accepted as an alias, but requests must
251259
not include both `vector_field` and `fields`.
252260

253261
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.
262+
`conversation_uuid`, `user_query`, `search_query`, `response`, and `sources`.
263+
`sources` is currently returned as a JSON string containing the retrieved source rows.
257264

258265
The table argument must be a plain table identifier, optionally qualified as
259266
`database.table`. The vector field argument must be a plain field identifier.
@@ -298,8 +305,7 @@ Behavior:
298305
| `conversation_uuid` | Existing or generated conversation id |
299306
| `user_query` | Original user query |
300307
| `search_query` | Standalone search query used for retrieval |
301-
| `response` | LLM answer with inline references like `[ref:<id>]` removed |
302-
| `response_with_refs` | Full LLM answer including inline source references like `[ref:<id>]`, where `<id>` is the source row id |
308+
| `response` | LLM answer as generated |
303309
| `sources` | JSON string containing retrieved source rows |
304310

305311
Example response shape:
@@ -310,12 +316,13 @@ Example response shape:
310316
"user_query": "What is vector search?",
311317
"search_query": "vector search, embeddings, similarity search",
312318
"response": "Vector search finds similar items by comparing embeddings...",
313-
"response_with_refs": "Vector search finds similar items by comparing embeddings [ref:1]...",
314319
"sources": "[{\"id\":1,\"title\":\"Vector Search\",\"content\":\"...\",\"knn_dist\":0.12}]"
315320
}
316321
```
317322

318-
Vector fields are intentionally absent from `sources`.
323+
Vector fields are intentionally absent from `sources`. If you want source citations,
324+
add citation instructions to `custom_prompt`; source row IDs are available in the
325+
context and `sources` payload.
319326

320327
## Model Management
321328

0 commit comments

Comments
 (0)