Run full-conversation summaries as a job, with nothing held open

A full run is silent for minutes while the model reads, and for the whole of
that silence the answer depended on a browser connection staying open. That
dependency is what has been failing, and no timeout could have fixed it:

    full: true, transcript_chars: 67720, elapsed_seconds: 40, chars_so_far: 0

Forty seconds, four heartbeats already delivered, nothing written yet, and a
model host that was up for seven days either side of it. The connection was
alive and being written to and then was not. Nothing in plMail closed it — a
held-open connection has a proxy, a browser and a network in it, and any of them
may end it without telling this application. Three rounds went into tuning
timeouts before the metrics panel said `cancelled` and named the real shape.

So the full run stops needing a connection. It dispatches SummariseThreadMessage
and answers 202; a worker on the maintenance transport does the work, writes to
the store and publishes a Mercure nudge; the card shows it as queued and fills
in when it lands. The reader may close the conversation, the tab, or the laptop
and still find the summary waiting — which is what somebody just told "this
takes several minutes" would reasonably expect anyway.

maintenance rather than ingest, deliberately: this holds a consumer for minutes,
and mail arriving must not queue behind somebody's summary.

The nudge carries no text. Same shape as JobNotifier and the insights strip —
the store is the record and a missed message costs one stale render — with one
reason of its own: what it would be carrying is a paragraph about somebody's
mail, on a channel that has never held any.

TWO BUGS RETIRED ON THE WAY. The streamed path is now never full, so the
transcript it hashes for freshness is always the one the reader's page
recomputes; a full run used to store a hash that could never be reproduced,
which would have shown every full summary as permanently out of date. And the
handler hashes the ORDINARY transcript for the same reason while sending the
full one to the model — the hash answers "has this conversation changed", and
the only answer that matters is the one the reader will ask.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5 committed 2026-09-03 14:28:25 +02:00
1 parent cae763cc26
commit 408fcd1bed
14 files changed
+540 -53

No files matched your search

@@ -428,6 +428,14 @@ export default class extends Controller {
if ("string" === typeof data.stream && "" !== data.stream) {
Turbo.renderStreamMessage(data.stream);
}
} else if (data.type === "summary.finished") {
// Published by App\Service\Ai\ThreadSummaryNotifier when the
// background job that writes a full-conversation summary has
// landed. Carries no text — the store is the record and the card
// re-reads it — for the reason every nudge here carries none, plus
// one of its own: what it would be carrying is a paragraph about
// somebody's mail, on a channel that has never held any.
this.dispatch("summary-finished", {detail: data});
} else if (data.type === "insights.changed") {
// Published by App\Service\Insight\InsightNotifier when a sync
// actually landed insights for this user. The strip above the mail
@@ -87,6 +87,15 @@ export default class extends Controller {
static values = {
url: String,
/**
* Which conversation this card is for.
*
* Needed because one Mercure topic carries the whole mailbox: a
* summary finishing for a thread the reader has since left must not
* redraw the one in front of them.
*/
thread: Number,
/**
* Its own per-action CSRF token, taken as a Stimulus value rather than
* from the `csrf-token` meta tag.
@@ -135,6 +144,7 @@ export default class extends Controller {
modelMissingLabel: { type: String, default: "No answer" },
refusedLabel: { type: String, default: "No answer" },
connectionLostLabel: { type: String, default: "The connection was lost." },
queuedLabel: { type: String, default: "Queued." },
unreachableLabel: { type: String, default: "No answer" },
tooShortLabel: { type: String, default: "No answer" },
};
@@ -221,7 +231,103 @@ export default class extends Controller {
full(event) {
event?.preventDefault();
this.#start(true);
this.#queue();
}
/**
* The nudge from the worker, for a job this card asked for.
*
* Wired from the thread's own markup to core--mercure:summary-finished.
* Filtered by thread id because one Mercure topic carries the whole
* mailbox: a summary finishing for a conversation the reader has since
* left must not redraw the one they are looking at.
*/
finished(event) {
if (Number(event?.detail?.threadId) !== Number(this.threadValue)) {
return;
}
if ("ready" !== event?.detail?.state) {
this.#settle(this.failedLabelValue);
this.#stopping(false);
return;
}
this.#adopt();
}
/**
* Ask for a full run and stop holding the connection.
*
* The POST returns as soon as the work is queued, so there is nothing to
* stream and nothing to keep open — which is the entire point of the job.
* What the card does next is wait for the nudge, and it waits just as well
* across a reload, because the answer is going to the store rather than
* down this request.
*/
async #queue() {
this.#stopReading();
this.#mount();
await this.#adopted();
this.#output("");
this.#stale(false);
this.#partial(false);
this.#offerFull(false);
this.#say(this.queuedLabelValue);
try {
const response = await fetch(this.urlValue, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRF-Token": this.tokenValue,
},
body: new URLSearchParams({ full: "1" }),
});
if (false === response.ok) {
this.#settle(await this.#reasonFor(response));
}
} catch {
// Only the ASKING failed, which is a short request that either
// arrives or does not. Nothing is running, so there is nothing to
// explain beyond that the request did not land.
this.#settle(this.connectionLostLabelValue);
}
}
/**
* Read what the worker stored and show it.
*
* A fetch rather than a page reload: the reader may be part-way down a long
* thread, and replacing the document to deliver a paragraph would move them
* — which is the cost the composer's own notes give for a Turbo visit.
*/
async #adopt() {
try {
const response = await fetch(this.urlValue, { headers: { Accept: "application/json" } });
if (false === response.ok) {
this.#settle(await this.#reasonFor(response));
return;
}
const stored = await response.json();
this.#answer = stored.text ?? "";
this.#output(this.#answer);
this.#stale(false === stored.fresh);
this.#partial(true === stored.partial);
this.#offerFull(true === stored.partial);
this.#settle("");
this.#stopping(false);
this.#offerAgain();
} catch {
this.#settle(this.connectionLostLabelValue);
}
}
/**
+7
View File
@@ -92,6 +92,13 @@ framework:
Symfony\Component\Mailer\Messenger\SendEmailMessage: export
Symfony\Component\Notifier\Message\ChatMessage: export
Symfony\Component\Notifier\Message\SmsMessage: export
# maintenance, and the choice is between two bad neighbours. This
# holds a consumer for minutes — the model reads the whole
# conversation before it writes a word — so it must not sit on
# ingest, where mail arriving would queue behind somebody's summary.
# maintenance is the transport whose work is already long and
# already nobody's foreground.
App\Infrastructure\Messaging\Message\SummariseThreadMessage: maintenance
App\Infrastructure\Messaging\Message\SyncImapMailboxMessage: ingest
# ingest, beside the sync it replaces for the common case: it is
# mail state arriving from the server, and it must not queue behind
+19 -7
View File
@@ -1,4 +1,4 @@
<!-- translated-from: features/mail.md sha1:58b0384fc5c26659556b26d1f222ea00bc7d69ce -->
<!-- translated-from: features/mail.md sha1:5e458cda7e18efb07344c406c74ae6962b63eee4 -->
# Mail
@@ -258,12 +258,24 @@ In beiden Fällen steht auf der Karte *„Das Gespräch war zu lang, um es ganz
darunter **Stattdessen das ganze Gespräch zusammenfassen**.
Dieser Knopf tut, was er sagt: Er schickt alles und bittet den Modell-Host, dafür Platz zu machen.
Rechne mit deutlich mehr Zeit, und damit, dass es der Maschine hinter dem Modell mehr abverlangt —
auf einer schwächeren kann das langsam genug sein, dass es sich nur für ein Gespräch lohnt, das du
wirklich brauchst. Es wird nichts gemerkt: Es gilt für die eine Zusammenfassung, um die du gebeten
hast, und für keine andere. Ist das Gespräch so lang, dass selbst das nicht reicht, bekommst du
trotzdem eine Zusammenfassung, der Hinweis bleibt stehen, und der Knopf verschwindet, statt dich
zweimal dasselbe abwarten zu lassen.
Rechne mit deutlich mehr Zeit — bei einem langen Verlauf eher Minuten als Sekunden — und damit, dass
es der Maschine hinter dem Modell mehr abverlangt. Es wird nichts gemerkt: Es gilt für die eine
Zusammenfassung, um die du gebeten hast, und für keine andere. Ist das Gespräch so lang, dass selbst
das nicht reicht, bekommst du trotzdem eine Zusammenfassung, der Hinweis bleibt stehen, und der
Knopf verschwindet, statt dich zweimal dasselbe abwarten zu lassen.
**Das läuft im Hintergrund, du musst nicht dabeibleiben.** Auf der Karte steht, dass es in der
Warteschlange ist, und danach bist du frei: Mach die Konversation zu, geh zu einer anderen, schließ
den Tab ganz. Die Zusammenfassung wird von einem Worker geschrieben und gespeichert, wenn sie fertig
ist — beim nächsten Öffnen des Gesprächs ist sie da, und wenn du noch draufschaust, taucht sie von
allein auf.
Das ist keine Bequemlichkeit, sondern der einzige Weg, auf dem es zuverlässig funktioniert. Eine
normale Zusammenfassung ist schnell genug, um ihr beim Ankommen zuzusehen — deshalb streamt sie. Eine
vollständige schweigt minutenlang, während das Modell liest, und eine Browser-Verbindung, die über
diese Stille offen gehalten wird, hat einen Proxy, ein Netzwerk und ein zuklappendes Notebook in
sich — jedes davon kann sie beenden, ohne jemandem Bescheid zu sagen. Die Arbeit an einen Worker zu
geben, beseitigt diese Abhängigkeit, statt sie zu überdauern zu versuchen.
Einmal geschrieben, wird sie aufbewahrt und beim nächsten Öffnen der Konversation ohne Wartezeit
wieder angezeigt. Kommt danach eine Antwort, siehst du sie ausgegraut mit dem Hinweis, dass sich das
+16 -5
View File
@@ -232,11 +232,22 @@ When either happens, the card says *"This conversation was too long to send in f
was written from part of it"*, with **Summarise the whole conversation instead** underneath.
That second button does what it says: it sends everything and asks the model host to make room for
it. Expect it to take noticeably longer, and expect it to ask more of the machine running the model —
on a modest one it may be slow enough to be worth doing only for a conversation you actually need.
Nothing is remembered, so it applies to the one summary you asked for and no others. If the
conversation is so long that even this cannot hold it, you still get a summary, the note stays, and
the button withdraws rather than inviting the same wait twice.
it. Expect it to take noticeably longer — minutes rather than seconds on a long thread — and expect
it to ask more of the machine running the model. Nothing is remembered, so it applies to the one
summary you asked for and no others. If the conversation is so long that even this cannot hold it,
you still get a summary, the note stays, and the button withdraws rather than inviting the same wait
twice.
**It runs in the background, and you do not have to wait for it.** The card says it is queued and
then you are free: close the conversation, go to another one, close the tab entirely. The summary is
written by a worker and stored when it is finished, and it will be on the card the next time you
open the thread — or it will appear there by itself if you are still looking at it.
That is not a convenience, it is the only way it works reliably. An ordinary summary is quick enough
to watch arrive, so it streams. A full one is silent for minutes while the model reads, and a
browser connection held open across that silence has a proxy, a network and a sleeping laptop in it
— any of which can end it without telling anyone. Handing the work to a worker removes the
dependency rather than trying to outlast it.
Once written, it is kept and shown the next time the conversation is opened, at no cost. If a reply
arrives afterwards, the summary is shown greyed with a note that the conversation has changed and a
+82 -40
View File
@@ -6,6 +6,7 @@ namespace App\Controller\Mail;
use App\Controller\ChecksCsrf;
use App\Entity\Mail\MessageThread;
use App\Infrastructure\Messaging\Message\SummariseThreadMessage;
use App\Entity\User\User;
use App\Security\Voter\OwnershipVoter;
use App\Service\Ai\OllamaClient;
@@ -17,6 +18,7 @@ use Symfony\Component\HttpFoundation\JsonResponse;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -58,15 +60,6 @@ final class ThreadSummaryController extends AbstractController
/** Above the upstream's own bound, so it is the upstream that gives up first. */
private const int STREAM_TIME_LIMIT_SECONDS = 300;
/**
* How far above the client's timeout PHP's own ceiling sits on a full run.
*
* Enough for the streamed answer itself plus the tidy, the insert and the
* closing frames — none of which is bounded by the idle timeout, because
* tokens are arriving by then.
*/
private const int STREAM_TIME_LIMIT_MARGIN_SECONDS = 180;
use ChecksCsrf;
public function __construct(
@@ -74,6 +67,7 @@ final class ThreadSummaryController extends AbstractController
private readonly ThreadTranscript $transcript,
private readonly ThreadSummaryStore $store,
private readonly LoggerInterface $logger,
private readonly MessageBusInterface $bus,
) {
}
@@ -147,7 +141,33 @@ final class ThreadSummaryController extends AbstractController
// to send is not that, and it is the one decision the card offers.
$full = $request->request->getBoolean('full');
$transcript = $this->transcript->forThread($thread, $full);
// A FULL RUN NEVER STREAMS, and that is the whole change.
//
// It is silent for minutes while the model reads, and for the whole of
// that silence the answer depended on a browser connection staying
// open. Measured on a real installation: abandoned after 40 seconds
// with nothing written, four heartbeats already on the wire, and a
// model host that was up for seven days either side. Nothing here
// closed it; a held-open connection has a proxy, a browser and a
// network in it, and any of them may end it without telling this
// application. No timeout could fix that, and three were tried.
//
// So it becomes a job. The worker has no connection to lose, and the
// reader may close the tab and still find the summary waiting. Since
// this returns before any model call, $transcript below is only ever
// the ordinary one — which also retires a bug that never shipped: the
// freshness hash is computed from what was SENT, and a full run stored
// a hash the reader's page could never reproduce, so every full summary
// would have shown as permanently out of date.
if (true === $full) {
$this->bus->dispatch(new SummariseThreadMessage((int) $thread->id, (int) $user->id));
// 202 and not 200: the work has been accepted and has not happened.
// The card shows it as queued and waits for the Mercure nudge.
return new JsonResponse(['status' => 'queued'], Response::HTTP_ACCEPTED);
}
$transcript = $this->transcript->forThread($thread);
$sourceHash = ThreadTranscript::hash($transcript);
$model = $this->summariser->model();
@@ -186,8 +206,8 @@ final class ThreadSummaryController extends AbstractController
// ComposeAssistController captures its user: reading a property inside
// the callback is free, while a repository lookup there would be a
// query against a kernel that has finished with the request.
$response = new StreamedResponse(function () use ($user, $thread, $transcript, $sourceHash, $model, $promptHash, $full): void {
$this->generate($user, $thread, $transcript, $sourceHash, $model, $promptHash, $full);
$response = new StreamedResponse(function () use ($user, $thread, $transcript, $sourceHash, $model, $promptHash): void {
$this->generate($user, $thread, $transcript, $sourceHash, $model, $promptHash);
});
// Not application/json: this is a sequence of JSON documents and never
@@ -219,26 +239,56 @@ final class ThreadSummaryController extends AbstractController
* freeing it is what cancels the call.
*/
/**
* PHP's ceiling for this run, always above the client's own.
* What is stored for this conversation, for a card that has been nudged.
*
* The margin is what the request does AFTER the last token — tidying,
* one insert, the closing frames — plus room for the client's timeout to
* fire and unwind normally, which is the outcome this ordering exists to
* guarantee. An ordinary run keeps exactly the 300 s it has always had.
* The other half of the background job: Mercure says "look again" and
* carries nothing, which is the shape JobNotifier and the insights strip
* already use — the store is the record, and a nudge that is missed costs
* one stale render rather than a wrong one. It also keeps a paragraph of
* assertions about somebody's mail off a message bus that has never carried
* any.
*
* GET and not part of the POST above, because the two are different events
* separated by minutes: one asks for the work, this reads the result, and a
* reader who reloaded the page in between must be able to do the second
* without repeating the first.
*/
private static function streamTimeLimitFor(string $transcript, bool $full): int
#[Route('/mail/thread/{id}/summary', name: 'app_mail_thread_summary_stored', requirements: ['id' => '\\d+'], methods: ['GET'])]
#[IsGranted('IS_AUTHENTICATED')]
public function stored(MessageThread $thread): Response
{
if (false === $full) {
return self::STREAM_TIME_LIMIT_SECONDS;
$user = $this->getUser();
if (false === $user instanceof User || false === $this->summariser->isAvailableFor($user)) {
return new JsonResponse(['error' => 'disabled'], Response::HTTP_CONFLICT);
}
return max(
self::STREAM_TIME_LIMIT_SECONDS,
(int) ceil(ThreadSummariser::timeoutFor($transcript)) + self::STREAM_TIME_LIMIT_MARGIN_SECONDS,
$this->denyAccessUnlessGranted(OwnershipVoter::OWN, $thread->account);
$transcript = $this->transcript->forThread($thread);
$summary = $this->store->forThread(
(int) $thread->id,
$this->summariser->model(),
$this->summariser->promptFingerprint(),
ThreadTranscript::hash($transcript),
ThreadTranscript::isPartial($transcript),
);
if (null === $summary) {
// Nothing stored under the current model and prompt. A job that
// failed leaves exactly this, and the card says so from the nudge's
// own state rather than inferring it from an absence.
return new JsonResponse(['error' => 'no_answer'], Response::HTTP_NOT_FOUND);
}
return new JsonResponse([
'text' => $summary->text,
'fresh' => $summary->isFresh,
'partial' => $summary->isPartial,
]);
}
private function generate(User $user, MessageThread $thread, string $transcript, string $sourceHash, string $model, string $promptHash, bool $full = false): void
private function generate(User $user, MessageThread $thread, string $transcript, string $sourceHash, string $model, string $promptHash): void
{
// PHP's default is to kill the script the moment a write finds the
// browser gone, which sounds like what we want and is the opposite of
@@ -286,9 +336,9 @@ final class ThreadSummaryController extends AbstractController
// way round, and PHP would kill the request while the client was still
// waiting patiently — which is the failure this line exists to prevent,
// reintroduced by the feature that needed it most.
set_time_limit(self::streamTimeLimitFor($transcript, $full));
set_time_limit(self::STREAM_TIME_LIMIT_SECONDS);
$tokens = $this->summariser->stream($user, $thread, $transcript, $full);
$tokens = $this->summariser->stream($user, $thread, $transcript);
if (null === $tokens) {
// Switched off between the check above and here, or a thread whose
@@ -311,11 +361,7 @@ final class ThreadSummaryController extends AbstractController
// cold barely moves it, so residency is not even the question any more.
$this->frame([
'type' => 'state',
'value' => match (true) {
true === $full => 'waiting_full',
true === $this->summariser->isModelWarm() => 'generating',
default => 'waiting',
},
'value' => true === $this->summariser->isModelWarm() ? 'generating' : 'waiting',
]);
// When the reading started, so each heartbeat can say how long it has
@@ -389,7 +435,6 @@ final class ThreadSummaryController extends AbstractController
$this->logger->warning('Thread summary abandoned before it finished', [
'thread' => $thread->id,
'model' => $model,
'full' => $full,
'transcript_chars' => mb_strlen($transcript),
'elapsed_seconds' => (int) round(microtime(true) - $readingSince),
'chars_so_far' => $this->written,
@@ -436,7 +481,6 @@ final class ThreadSummaryController extends AbstractController
// still short — and three reports of `kind: timeout` could not
// be told apart because neither the mode nor the size was in
// the record.
'full' => $full,
'transcript_chars' => mb_strlen($transcript),
]);
@@ -455,7 +499,7 @@ final class ThreadSummaryController extends AbstractController
// nothing the session holds. Its failure is logged and swallowed inside
// the store: the reader already has their summary on screen, and a
// caching miss must not become a 500 on a response that is half sent.
$this->store->save((int) $thread->id, $result->content, $sourceHash, $model, $promptHash, $full);
$this->store->save((int) $thread->id, $result->content, $sourceHash, $model, $promptHash);
$this->frame([
'type' => 'done',
@@ -465,12 +509,10 @@ final class ThreadSummaryController extends AbstractController
// "written from part of it" is the one caveat they cannot get from
// reading the paragraph.
'partial' => ThreadTranscript::isPartial($transcript),
// Whether this run sent the whole conversation, so the card knows
// the offer has been taken up and stops making it. Distinct from
// `partial`: a thread past FULL_TRANSCRIPT_CEILING is both — sent
// in full mode and still trimmed — and the reader needs to be told
// that pressing it again will not help.
'full' => $full,
// Always false from here: a full run is a job now and never
// reaches this frame. Kept in the contract because the card reads
// it to decide whether the offer still stands.
'full' => false,
// The whole answer again, tidied. The browser has the raw tokens
// and could join them itself, but tidy() strips a code fence that
// can only be recognised from both ends — so what is SHOWN from
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Messaging\Handler;
use App\Infrastructure\Messaging\Message\SummariseThreadMessage;
use App\Repository\Mail\MessageThreadRepository;
use App\Repository\User\UserRepository;
use App\Service\Ai\ThreadSummariser;
use App\Service\Ai\ThreadSummaryNotifier;
use App\Service\Ai\ThreadSummaryStore;
use App\Service\Ai\ThreadTranscript;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Writes a full-conversation summary with nobody waiting on the other end.
*
* The whole point is what is ABSENT here: no StreamedResponse, no
* connection_aborted(), no set_time_limit() racing an idle timeout, and no
* frames whose delivery anything can interrupt. The model is asked, the answer
* is stored, and a nudge goes out. A reader who closed the tab thirty seconds in
* gets the summary anyway, because nothing about producing it was theirs to
* hold open.
*
* WHY THE STREAM IS ITERATED AND THROWN AWAY
* ──────────────────────────────────────────
* ThreadSummariser::stream() is a generator and its RETURN is the finished,
* tidied answer — the tokens are for a reader watching, and there is not one.
* Iterating to the end is how the return becomes available and how
* AiAssistant::recorded() gets to file an honest metrics row; abandoning it
* would record the call as cancelled, which is the word for a reader who left
* and would be a lie about a worker that did the job.
*
* THE PERMISSION IS CHECKED AGAIN, for EmbedMessagesHandler's reason with a
* sharper edge: an envelope outlives the request that made it, an administrator
* may switch summaries off in between, and this one costs minutes of somebody
* else's GPU. stream() refuses on its own if the feature is off — it is asked
* here so the refusal is a logged decision rather than a silent null.
*/
#[AsMessageHandler]
final readonly class SummariseThreadHandler
{
public function __construct(
private MessageThreadRepository $threads,
private UserRepository $users,
private ThreadSummariser $summariser,
private ThreadTranscript $transcript,
private ThreadSummaryStore $store,
private ThreadSummaryNotifier $notifier,
private LoggerInterface $logger,
) {
}
public function __invoke(SummariseThreadMessage $message): void
{
$user = $this->users->find($message->userId);
$thread = $this->threads->find($message->threadId);
if (null === $user || null === $thread) {
// Deleted between the press and the worker. Not a failure worth
// retrying, and not worth a nudge either — the card it would reach
// is on a page that no longer has the thread.
return;
}
if (false === $this->summariser->isAvailableFor($user)
|| false === ThreadSummariser::hasEnoughToSummarise($thread)) {
$this->logger->info('SummariseThreadHandler: refused before asking the model', [
'thread' => $message->threadId,
]);
$this->notifier->finished($message->userId, $message->threadId, 'failed');
return;
}
// The FULL transcript, which is the entire reason this job exists.
$transcript = $this->transcript->forThread($thread, true);
$tokens = $this->summariser->stream($user, $thread, $transcript, true);
if (null === $tokens) {
$this->notifier->finished($message->userId, $message->threadId, 'failed');
return;
}
foreach ($tokens as $ignored) {
// Drained, not used. See the class docblock: the tokens are for a
// reader and the return is the answer.
}
$result = $tokens->getReturn();
if (false === $result->succeeded || null === $result->content) {
// Logged at error for the reason ThreadSummaryController logs its
// failures there: this is the only record of a user-visible
// operation that failed, and at warning it is removable by the
// capture level an operator raises to quieten a busy install.
$this->logger->error('Thread summary job failed', [
'thread' => $message->threadId,
'kind' => $result->errorKind,
'model' => $this->summariser->model(),
'transcript_chars' => mb_strlen($transcript),
]);
$this->notifier->finished($message->userId, $message->threadId, 'failed');
return;
}
// HASHED OVER THE ORDINARY TRANSCRIPT, NOT THE ONE THAT WAS SENT, and
// getting this backwards would have made the whole job pointless.
//
// The hash answers one question — has this conversation changed since
// the summary was written — and it is answered by comparing against
// whatever the READER's page computes when it opens the thread. That is
// always the ordinary transcript, because the page has no idea a full
// run ever happened. Store the full transcript's hash and the two can
// never match: the summary arrives, and the card shows it greyed out as
// out of date, for ever, with a button offering to write it again.
//
// Both derive from the same messages, so either is a faithful answer to
// the question. Only one of them is the answer the reader will ask.
$this->store->save(
$message->threadId,
$result->content,
ThreadTranscript::hash($this->transcript->forThread($thread)),
$this->summariser->model(),
$this->summariser->promptFingerprint(),
true,
);
$this->notifier->finished($message->userId, $message->threadId, 'ready');
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Messaging\Message;
/**
* Summarise one conversation from the whole of it, away from any connection.
*
* WHY THIS IS A JOB AND THE ORDINARY SUMMARY IS NOT
* ─────────────────────────────────────────────────
* The streamed endpoint is right for the ordinary case: half a minute, tokens
* arriving as they are written, and a reader watching them. It is wrong for the
* full-conversation run, which is silent for minutes while the model reads and
* only then begins — and for the whole of that silence the answer depends on a
* browser connection staying open.
*
* That dependency is what broke, and not in a way any timeout could fix.
* Measured on a real installation: a full run over 67,720 characters, abandoned
* after 40 seconds with nothing yet written, while the model host sat healthy
* for seven days either side of it. The heartbeat had put four frames on the
* wire, so the connection was alive and being written to and then was not.
* Nothing in plMail closed it. A held-open connection has a proxy, a browser
* and a network in it, and any of them may end it for reasons this application
* does not get told.
*
* So the full run stops needing one. The work is dispatched, a worker does it,
* the store gets the answer, and Mercure says so. The reader may close the tab,
* navigate away or lose their network entirely and still find the summary
* waiting — which is the behaviour somebody who has just been told "this takes
* several minutes" would reasonably expect anyway.
*
* IDS AND NOT OBJECTS, like every other message here: an envelope outlives the
* request that made it, and a serialised entity is a snapshot that can be wrong
* by the time it is read.
*/
final readonly class SummariseThreadMessage
{
public function __construct(
public int $threadId,
/**
* Whose mail this is.
*
* Carried rather than looked up, because the handler has to answer two
* questions with it and neither is safe to skip: whether this person is
* still allowed the feature — an administrator may have switched it off
* between the press and the worker picking the envelope up — and where
* to publish the Mercure nudge. A job that ran on a permission it no
* longer had would be the worst kind, because nobody is watching.
*/
public int $userId,
) {
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Service\Ai;
use App\Domain\Helper\ThrowableSeverity;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
/**
* Tell an open thread that its summary has landed.
*
* A NUDGE AND NOT THE TEXT, which is the shape JobNotifier, RuleRunNotifier and
* the insights strip all use, and here it earns its place twice over. The store
* is the record, so a page that missed a message is one render behind rather
* than wrong — and the thing not being carried is a paragraph of assertions
* about somebody's mail. The topic is per-user and the subscriber cookie is
* authenticated, so putting it on the bus would not be a leak; it would just be
* mail on a channel that has never carried any, for no gain over "look again".
*/
final readonly class ThreadSummaryNotifier
{
public function __construct(
private HubInterface $hub,
private LoggerInterface $logger,
) {
}
/** @param 'ready'|'failed' $state */
public function finished(int $userId, int $threadId, string $state): void
{
try {
$this->hub->publish(new Update(
topics: [sprintf('mail/user/%d', $userId)],
data: (string) json_encode([
'type' => 'summary.finished',
'threadId' => $threadId,
'state' => $state,
]),
));
} catch (\Throwable $e) {
// A notification is the doorbell, not the delivery — JobNotifier's
// words, and the same reasoning: the summary is already committed,
// so throwing here cannot undo it. It would only fail a handler
// that succeeded and have Messenger run a nine-minute model call a
// second time. The cost of swallowing is a card that waits for its
// next page load, where the stored summary is sitting anyway.
$this->logger->log(
ThrowableSeverity::level($e, LogLevel::WARNING),
'ThreadSummaryNotifier: publish failed',
['threadId' => $threadId, 'error' => $e->getMessage(), 'exception' => $e],
);
}
}
}
+6
View File
@@ -77,6 +77,11 @@ of this that does not exist yet when the controller has to be listening.
{% if ai_summary_enabled() and thread.messageCount > 1 %}
data-controller="mail--thread-summary"
data-mail--thread-summary-url-value="{{ path('app_mail_thread_summary', {'id': thread.id}) }}"
data-mail--thread-summary-thread-value="{{ thread.id }}"
{# The worker's nudge. Full-conversation summaries are a background job
— see SummariseThreadMessage for why — so the answer arrives here
rather than down the request that asked for it. #}
data-action="core--mercure:summary-finished@document->mail--thread-summary#finished"
{# A per-action token, stamped here because assets/csrf.js reads the
`csrf-token` meta tag and that tag carries the shared `ajax` one.
The precedent is settings/account_order_controller, which csrf.js's
@@ -97,6 +102,7 @@ of this that does not exist yet when the controller has to be listening.
data-mail--thread-summary-waiting-full-label-value="{{ 'thread.summary.state.waiting_full'|trans }}"
data-mail--thread-summary-reading-label-value="{{ 'thread.summary.state.reading'|trans }}"
data-mail--thread-summary-connection-lost-label-value="{{ 'thread.summary.error.connection_lost'|trans }}"
data-mail--thread-summary-queued-label-value="{{ 'thread.summary.state.queued'|trans }}"
data-mail--thread-summary-too-short-label-value="{{ 'thread.summary.too_short'|trans }}"
{% endif %}
{% endmacro %}
@@ -212,6 +212,49 @@ final class ThreadSummaryTest extends WebTestCase
self::assertSame('A summary.', $frames[array_key_last($frames)]['text']);
}
/**
* A full run is QUEUED, not streamed, and nothing is held open.
*
* The whole reason the job exists. A full-conversation run is silent for
* minutes while the model reads, and for that whole silence the answer used
* to depend on a browser connection staying open. Measured on a real
* installation: abandoned after 40 seconds with nothing yet written, four
* heartbeats already delivered, and a model host that was up for seven days
* either side of it. Nothing in plMail closed that connection — a proxy, a
* browser and a network are all in it, and any of them may end it without
* saying so. Three rounds of timeout tuning could not fix that, because a
* timeout was never what went wrong.
*
* So this asserts the absence: 202, a JSON body, and NO ndjson stream. If
* this ever goes back to 200 with a Content-Type of application/x-ndjson,
* the connection dependency is back and so is the bug.
*/
public function testAFullRunIsQueuedRatherThanStreamed(): void
{
$client = $this->signIn();
$thread = $this->seedThread(2);
$this->configureAi();
$client->request(
'POST',
sprintf('/mail/thread/%d/summary', $thread->id),
parameters: ['full' => '1'],
server: ['HTTP_X_CSRF_TOKEN' => $this->token($client)],
);
self::assertSame(202, $client->getResponse()->getStatusCode());
self::assertJsonStringEqualsJsonString(
'{"status":"queued"}',
(string) $client->getResponse()->getContent(),
);
self::assertStringNotContainsString(
'ndjson',
(string) $client->getResponse()->headers->get('Content-Type'),
'a full run must not stream, or it depends on a connection again',
);
}
/**
* A finished run is stored, so the next open costs nothing.
*
+1
View File
@@ -3412,6 +3412,7 @@ thread:
sent: 'An das Modell geschickt …'
stopped: 'Angehalten. Nichts wurde gespeichert.'
waiting: 'Die erste Anfrage nach einer Weile dauert etwa eine Minute, solange das Modell lädt und das Gespräch liest.'
queued: 'In der Warteschlange. Das ganze Gespräch wird im Hintergrund zusammengefasst — du kannst das hier zumachen und später wiederkommen, dann ist es da.'
reading: 'Liest das Gespräch noch — %elapsed% bisher. Bis es fertig ist, passiert sichtbar nichts.'
waiting_full: 'Liest das ganze Gespräch. Bei einem langen Verlauf kann das mehrere Minuten dauern, und bis es fertig gelesen hat, passiert sichtbar nichts.'
working: 'Fasst zusammen …'
+1
View File
@@ -3404,6 +3404,7 @@ thread:
sent: 'Sent to the model…'
stopped: 'Stopped. Nothing was saved.'
waiting: 'The first request after a while takes about a minute while the model loads and reads the conversation.'
queued: 'Queued. The whole conversation is being summarised in the background — you can close this and come back; it will be here.'
reading: 'Still reading the conversation — %elapsed% so far. Nothing appears until it has finished.'
waiting_full: 'Reading the whole conversation. This can take several minutes on a long thread, and nothing appears until it has finished reading.'
working: 'Summarising…'
+1
View File
@@ -2988,6 +2988,7 @@ thread:
sent: "Sent t' th' oracle…"
stopped: 'Belayed. Nowt were kept.'
waiting: "Th' first askin' after a spell takes near a minute while th' oracle wakes an' reads th' yarn."
queued: "In th' queue. Th' whole yarn be boiled down below decks — ye may close this an' come back; it be waitin'."
reading: "Still readin' th' yarn — %elapsed% so far. Nowt shows till she be done."
waiting_full: "Readin' th' whole yarn. On a long one that be several minutes, an' nowt shows till she has read it through."
working: "Boilin' her down…"