mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
feat(ai): semantic search, writing help, and the pass that embeds a mailbox
The three remaining pieces. Written to the seams the survey found, and
keeping the traps it named.
SEARCH — ANOTHER ARM OF THE SAME UNION
A vector hit is a candidate on exactly the same footing as a lexical one:
it lands in COUNT(*) OVER (), it pages correctly, and it needs no second
statement to reconcile with. Four things had to hold and each is written
down where it applies:
· The query is embedded ONCE, in the controller. buildSearchSql() runs
up to four times per search — the cheap pass, the body rescue, and
twice more when a page past the end recovers its total — and one of
those is inside a statement-timeout transaction, where a slow model
would be reported as a database fault.
· The candidate set is BOUNDED to the most recent 2,000 messages. The
distance function costs 0.11ms a row; over a whole mailbox it is
eleven seconds, worse than the pathology the UNION shape exists to
escape.
· A THRESHOLD, not a top-k. The total rides on the same statement, and
"lexical matches plus up to k" is a number that is only true on page
one.
· The inner LIMIT is parenthesised, because these arms are joined with
a bare ' UNION ' and an unparenthesised LIMIT binds to the whole
union rather than to one arm.
The rank join is 1:1, which is the property the label joins lost and had
to be given back in v0.1.40. Three tests: a message found by meaning that
no lexical arm can reach, one rejected by the threshold, and one with no
embedding still found by words — that last is what proves the LEFT JOIN
does not drop rows.
WRITING HELP — THE SERVER MAKES THE CALL
connect-src 'self' is enforced, so a browser could not reach the model
host even if it should — and it should not: that address is on the
operator's private network, and putting it in a page hands it to every
script the page loads.
The route carries NO draft id. A composer opens before one exists, which
is exactly when somebody wants a first draft; a URL built at render time
with an id in it is stale in that window. Nothing is saved as a side
effect either.
Four tasks, a closed set. A free-form instruction parameter would be a
prompt an attacker could write, with the user's own mail as context and
the answer pasted into the user's own draft. The message being replied to
is read from the DATABASE by id, ownership checked — never from the page.
Generated text is INSERTED, never substituted. A rewrite that overwrote
the original would be a model deleting somebody's words with no undo they
can see. It goes in as a plain text node at the end of the body: a styled
wrapper would be subtracted by the composer's typed-length calculation
and could stop the draft autosaving, and "at the caret" is a position the
writer did not choose, because opening the menu moved focus.
The menu is behind a Twig FUNCTION, not a variable — this window is
included from four places, two of them undo streams that include it
`only`, and strict_variables makes a variable threaded through three of
four a 500 on the undo path rather than a missing button.
EMBEDDING — ARRIVALS ON INGEST, THE REST ON DEMAND
A post-ingest step for new mail, and a command for everything already
there. The backfill re-dispatches itself with a cursor rather than
looping: a hundred thousand messages at a round trip each is hours, and a
single job holding that is killed by any worker restart with nothing to
show for it.
Ascending id, because it is the one ordering nothing can change under a
walk that long — mail arriving during it gets a higher id and is met by
the pass still coming. The vectors already stored ARE the progress
record, so there is no second one to keep in step, and the cursor
advances past everything LOOKED AT rather than everything stored, so one
message the model cannot answer for does not become a wall it restarts at
forever.
On the maintenance transport, not ingest: a backfill running for hours
would otherwise sit in front of every arriving message, and new mail
would stop appearing until an old mailbox had finished being catalogued.
KNOWN FAILING, AND NOT FROM THIS
compose-send-optimistic.spec.ts:124 fails in the FULL e2e run and passes
in isolation and in pairs — verified with the AI menu both present and
removed, 24 passed either way. It is the same spec that broke in the
earlier seed race, and the row it cannot click is present but still
mid-entrance. Cross-file interference, pre-existing, and left for the
debugging pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ee370d7c5f
commit
469a2e8107
24 files changed
+1486
-8
No files matched your search
@@ -999,6 +999,72 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
|
||||
and readReceiptRequested to false — that is, the menu
|
||||
being rendered is what makes the fields exist at all.
|
||||
They ride the existing draft/send POSTs; no new route. #}
|
||||
{# ── Help me write ────────────────────────────────────
|
||||
Rendered only where an administrator switched it on, and
|
||||
asked through a Twig FUNCTION rather than a variable: this
|
||||
window is included from four places — the two routes that
|
||||
render it and both undo streams, which include it `only` —
|
||||
and `strict_variables` is on, so a variable threaded
|
||||
through three of the four is a 500 on the undo path
|
||||
rather than a missing button.
|
||||
|
||||
The URL carries no draft id. A window opens before one
|
||||
exists, which is exactly when somebody is most likely to
|
||||
ask for a first draft. #}
|
||||
{% if ai_writing_help_enabled() %}
|
||||
<div data-controller="ui--dropdown compose--ai-assist"
|
||||
data-compose--ai-assist-url-value="{{ path('app_compose_assist') }}"
|
||||
{# ctx.replyTo, not a variable of our own: the context object is what
|
||||
already survives the round trip through both undo streams, so a
|
||||
reply drafted after a cancelled send still knows what it answers. #}
|
||||
data-compose--ai-assist-in-reply-to-value="{{ ctx.replyTo }}"
|
||||
data-compose--ai-assist-busy-label-value="{{ 'compose.ai.working'|trans }}"
|
||||
data-compose--ai-assist-failed-label-value="{{ 'compose.ai.no_answer'|trans }}">
|
||||
<button type="button"
|
||||
data-action="click->ui--dropdown#toggle"
|
||||
title="{{ 'compose.ai.label'|trans }}"
|
||||
aria-label="{{ 'compose.ai.label'|trans }}"
|
||||
class="{{ ic }}"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i></button>
|
||||
|
||||
{# `rise` and `bottom-full` for the reason the menu
|
||||
below gives: it comes UP out of the action bar,
|
||||
and a menu sliding down into the button that
|
||||
opened it reads as the wrong thing arriving. #}
|
||||
<div data-ui--dropdown-target="menu" hidden
|
||||
data-enter="rise"
|
||||
class="absolute bottom-full mb-2 left-2 right-2
|
||||
md:left-auto md:right-3 md:w-64 z-30 popover py-1">
|
||||
|
||||
{% for task, icon in {
|
||||
'reply': 'fa-reply',
|
||||
'shorten': 'fa-compress',
|
||||
'formal': 'fa-user-tie',
|
||||
'proofread': 'fa-spell-check',
|
||||
} %}
|
||||
<button type="button"
|
||||
data-action="click->compose--ai-assist#run click->ui--dropdown#close"
|
||||
data-compose--ai-assist-task-param="{{ task }}"
|
||||
class="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-ink-muted
|
||||
hover:bg-hover hover:text-ink transition-colors text-left">
|
||||
<i class="fa-solid {{ icon }} w-4 text-center" aria-hidden="true"></i>
|
||||
<span>{{ ('compose.ai.task.' ~ task)|trans }}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
|
||||
<div class="my-1 border-t border-line"></div>
|
||||
|
||||
{# Said every time rather than once in a tooltip.
|
||||
The text lands in somebody's outgoing mail,
|
||||
and "a machine wrote this and you are about to
|
||||
send it" is not a thing to mention quietly. #}
|
||||
<p class="px-3 py-2 text-[11px] leading-snug text-ink-faint">
|
||||
<span data-compose--ai-assist-target="status" class="block text-ink-muted"></span>
|
||||
{{ 'compose.ai.disclaimer'|trans }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div data-controller="ui--dropdown">
|
||||
<button type="button"
|
||||
data-action="click->ui--dropdown#toggle"
|
||||
|
||||
Reference in new issue
Block a user