Deep Research: NDJSON streaming so the connection survives long runs
Previously the endpoint returned a single JSON object at the end. Apache+ PHP-FPM buffers the entire body until PHP exits, so a 160s azure_full run caused the browser to drop the fetch as "Failed to fetch" while the server was still synthesising — the response then arrived to a dead socket. Switch to application/x-ndjson with one event per line. The endpoint emits 'progress', 'start', 'step' (running/complete/warning/error), 'subq', and a final 'final' event carrying the full result payload. Output buffering is explicitly disabled so each line flushes through Apache as soon as the agent emits it. DbnDeepResearchAgent::run() now accepts an optional ?callable $emit and fires step:running before each step + step:complete after, plus a subq event per sub-question retrieval round. JS reads response.body as a stream, splits on newlines, updates the trace panel live, and renders the final result when the final event arrives. Status pill shows live progress detail (e.g. "Synthesising with Azure gpt-4o — this is the slowest step…"). Engine row in the form now shows expected duration per engine (~15-45s mini, ~60-180s full, ~30-90s GPU) so users know what they're in for before clicking Run.
This commit is contained in:
@@ -30,7 +30,8 @@ final class DbnDeepResearchAgent
|
||||
array $sliceSelection,
|
||||
string $engine,
|
||||
string $language,
|
||||
array $controls
|
||||
array $controls,
|
||||
?callable $emit = null
|
||||
): array {
|
||||
$seedQuery = trim($seedQuery);
|
||||
$pastedText = trim($pastedText);
|
||||
@@ -58,31 +59,49 @@ final class DbnDeepResearchAgent
|
||||
$trace = [];
|
||||
$seedDescription = $this->buildSeedDescription($seedQuery, $pastedText, $uploadedFiles);
|
||||
|
||||
// STEP 1: Query interpretation — build research brief
|
||||
$emitStep = function (string $stepId, string $label, string $detail, string $status) use (&$trace, $emit): void {
|
||||
$trace[] = $this->trace($label, $detail, $status);
|
||||
if ($emit) {
|
||||
$emit('step', [
|
||||
'step' => $stepId,
|
||||
'label' => $label,
|
||||
'detail' => $detail,
|
||||
'status' => $status,
|
||||
]);
|
||||
}
|
||||
};
|
||||
$emitRunning = function (string $stepId, string $label, string $detail = 'Running…') use ($emit): void {
|
||||
if ($emit) {
|
||||
$emit('step', [
|
||||
'step' => $stepId,
|
||||
'label' => $label,
|
||||
'detail' => $detail,
|
||||
'status' => 'running',
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
// STEP 1: Query interpretation
|
||||
$emitRunning('interpretation', 'Query interpretation', 'Summarising the seed input…');
|
||||
$stepStart = microtime(true);
|
||||
$interpretation = $this->interpretSeed($seedDescription, $language);
|
||||
$this->stepTimings['interpretation'] = $this->elapsedMs($stepStart);
|
||||
$trace[] = $this->trace(
|
||||
'Query interpretation',
|
||||
$interpretation['detail'],
|
||||
'complete'
|
||||
);
|
||||
$emitStep('interpretation', 'Query interpretation', $interpretation['detail'], 'complete');
|
||||
|
||||
// STEP 2: Query expansion
|
||||
$emitRunning('expansion', 'Query expansion', 'Generating sub-questions…');
|
||||
$stepStart = microtime(true);
|
||||
$expansion = $this->expandQueries($seedDescription, $interpretation['brief'], $controls['sub_q_count'], $language);
|
||||
$this->stepTimings['expansion'] = $this->elapsedMs($stepStart);
|
||||
$subQuestions = $expansion['questions'];
|
||||
$expansionStatus = $expansion['fallback'] ? 'warning' : 'complete';
|
||||
$trace[] = $this->trace(
|
||||
'Query expansion',
|
||||
$expansion['fallback']
|
||||
? 'Could not parse sub-questions; falling back to retrieving on the seed query alone.'
|
||||
: sprintf('Generated %d sub-questions to research the corpus from multiple angles.', count($subQuestions)),
|
||||
$expansionStatus
|
||||
);
|
||||
$expansionDetail = $expansion['fallback']
|
||||
? 'Could not parse sub-questions; falling back to retrieving on the seed query alone.'
|
||||
: sprintf('Generated %d sub-questions to research the corpus from multiple angles.', count($subQuestions));
|
||||
$emitStep('expansion', 'Query expansion', $expansionDetail, $expansionStatus);
|
||||
|
||||
// STEP 3: Slice resolution
|
||||
$emitRunning('slice_resolution', 'Slice resolution', 'Resolving slice toggles to document IDs…');
|
||||
$stepStart = microtime(true);
|
||||
$sliceSelectionNormalized = dbnV6NormalizeSliceSelection($sliceSelection);
|
||||
if (!array_filter($sliceSelectionNormalized)) {
|
||||
@@ -104,9 +123,12 @@ final class DbnDeepResearchAgent
|
||||
$sliceDetail = 'Slice resolution failed; corpus search will run unconstrained.';
|
||||
}
|
||||
$this->stepTimings['slice_resolution'] = $this->elapsedMs($stepStart);
|
||||
$trace[] = $this->trace('Slice resolution', $sliceDetail, $sliceStatus);
|
||||
$emitStep('slice_resolution', 'Slice resolution', $sliceDetail, $sliceStatus);
|
||||
|
||||
// STEP 4: Upload indexing (in-memory, ephemeral)
|
||||
$emitRunning('upload_indexing', 'Upload indexing', empty($uploadedFiles)
|
||||
? 'No uploads; skipping…'
|
||||
: sprintf('Chunking + embedding %d file(s) in memory…', count($uploadedFiles)));
|
||||
$stepStart = microtime(true);
|
||||
$uploadChunks = [];
|
||||
foreach ($uploadedFiles as $idx => $file) {
|
||||
@@ -141,15 +163,16 @@ final class DbnDeepResearchAgent
|
||||
$uploadDetail = 'No files uploaded; agent will research the corpus only.';
|
||||
}
|
||||
$this->stepTimings['upload_indexing'] = $this->elapsedMs($stepStart);
|
||||
$trace[] = $this->trace('Upload indexing', $uploadDetail, $uploadStatus);
|
||||
$emitStep('upload_indexing', 'Upload indexing', $uploadDetail, $uploadStatus);
|
||||
|
||||
// STEP 5: Retrieval (per sub-question)
|
||||
$stepStart = microtime(true);
|
||||
$retrievalQueries = $subQuestions ?: [[
|
||||
'id' => 'q1',
|
||||
'question' => $seedQuery !== '' ? $seedQuery : ($interpretation['brief'] ?: 'legal research'),
|
||||
'rationale' => 'Seed query (no sub-question expansion).',
|
||||
]];
|
||||
$emitRunning('retrieval', 'Retrieval', sprintf('Hybrid vector + keyword + rerank across %d sub-question(s)…', count($retrievalQueries)));
|
||||
$stepStart = microtime(true);
|
||||
|
||||
try {
|
||||
$rag = new ClientRagPipeline((int)$client['id'], 'http://10.0.1.10:4000', 60);
|
||||
@@ -159,7 +182,15 @@ final class DbnDeepResearchAgent
|
||||
|
||||
$rawPool = [];
|
||||
$retrievalWarnings = 0;
|
||||
foreach ($retrievalQueries as $sq) {
|
||||
foreach ($retrievalQueries as $idx => $sq) {
|
||||
if ($emit) {
|
||||
$emit('subq', [
|
||||
'index' => $idx + 1,
|
||||
'total' => count($retrievalQueries),
|
||||
'id' => $sq['id'],
|
||||
'question' => $sq['question'],
|
||||
]);
|
||||
}
|
||||
try {
|
||||
$corpusChunks = $rag->searchAll(
|
||||
$sq['question'],
|
||||
@@ -197,22 +228,21 @@ final class DbnDeepResearchAgent
|
||||
$merged = $this->mergeAndDedupe($rawPool, self::POOL_CAP);
|
||||
$this->stepTimings['retrieval'] = $this->elapsedMs($stepStart);
|
||||
$retrievalStatus = $retrievalWarnings > 0 ? 'warning' : 'complete';
|
||||
$trace[] = $this->trace(
|
||||
'Retrieval',
|
||||
sprintf(
|
||||
'%d sub-question(s) × hybrid + RRF + rerank → %d raw chunks → %d unique after dedupe.',
|
||||
count($retrievalQueries),
|
||||
count($rawPool),
|
||||
count($merged)
|
||||
),
|
||||
$retrievalStatus
|
||||
$retrievalDetail = sprintf(
|
||||
'%d sub-question(s) × hybrid + RRF + rerank → %d raw chunks → %d unique after dedupe.',
|
||||
count($retrievalQueries),
|
||||
count($rawPool),
|
||||
count($merged)
|
||||
);
|
||||
$emitStep('retrieval', 'Retrieval', $retrievalDetail, $retrievalStatus);
|
||||
|
||||
// Cap pool to reranker top-K for synthesis
|
||||
$synthesisPool = array_slice($merged, 0, $controls['reranker_top_k']);
|
||||
$numberedSources = $this->numberSources($synthesisPool);
|
||||
|
||||
// STEP 6: Synthesis
|
||||
$synthesisEngineLabel = $engine === 'azure_full' ? 'Azure gpt-4o' : ($engine === 'gpu' ? 'GPU qwen2.5:14b' : 'Azure gpt-4o-mini');
|
||||
$emitRunning('synthesis', 'Synthesis', sprintf('Synthesising cited brief with %s — this is the slowest step…', $synthesisEngineLabel));
|
||||
$stepStart = microtime(true);
|
||||
$synthesis = $this->synthesise(
|
||||
$seedDescription,
|
||||
@@ -224,7 +254,8 @@ final class DbnDeepResearchAgent
|
||||
$controls['temperature']
|
||||
);
|
||||
$this->stepTimings['synthesis'] = $this->elapsedMs($stepStart);
|
||||
$trace[] = $this->trace(
|
||||
$emitStep(
|
||||
'synthesis',
|
||||
'Synthesis',
|
||||
sprintf('%s synthesised the brief using %d grounded source(s).', $synthesis['deploy_label'], count($numberedSources)),
|
||||
'complete'
|
||||
@@ -232,7 +263,8 @@ final class DbnDeepResearchAgent
|
||||
|
||||
// STEP 7: Confidence
|
||||
$confidence = $this->citationConfidence($numberedSources);
|
||||
$trace[] = $this->trace(
|
||||
$emitStep(
|
||||
'confidence',
|
||||
'Citation confidence',
|
||||
sprintf('%s confidence based on %d source(s) and reranker score distribution.', ucfirst($confidence), count($numberedSources)),
|
||||
$confidence === 'low' ? 'warning' : 'complete'
|
||||
|
||||
Reference in New Issue
Block a user