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:
2026-05-15 10:47:35 +02:00
parent 4cbe0a4ac4
commit a1a7f442a7
4 changed files with 296 additions and 77 deletions
+61 -29
View File
@@ -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'