Show the language model's opinion on a message
When inbuxa-server's AI spam classification is on, it records the model's answer in an X-Spam-LLM header: a tag (LLM_<category>[_<confidence>]) and, in parentheses, the model's explanation. The full message now asks for it, and where it's there: - the message details show "Language model's opinion" beside the spam filter's own working, with category, confidence and explanation; - a message in Junk carries a banner saying the same. Both say it's one of several signals the spam filter weighed, never the reason on its own, as the server's spec requires. The explanation is model output and is only ever rendered as text. Nothing shows without the header, so a server without the feature, or with it off, looks as before. Translations: two new strings, "Language model's opinion" and "One of several signals the spam filter weighed", in all eight catalogues (16 entries). Category and confidence come from the server and aren't translated.
This commit is contained in:
1 parent
c0c892dd33
commit
e4926cfa7d
17 files changed
+319
No files matched your search
@@ -0,0 +1,67 @@
|
||||
import { Bot } from "lucide-react";
|
||||
import type { LlmOpinion } from "@/lib/llmOpinion";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
|
||||
/*
|
||||
* inbuxa: the language model's opinion on a message, where the server's AI
|
||||
* spam classification recorded one (lib/llmOpinion).
|
||||
*
|
||||
* Two rules, both from the server's spec. It is always labeled as one signal
|
||||
* the spam filter weighed among several, never as the reason a message was
|
||||
* filed where it was: the model can add a bounded amount to the score and no
|
||||
* more. And the explanation is the model's own output, so it is only ever
|
||||
* rendered as text.
|
||||
*
|
||||
* The category and confidence come from the server's configuration and aren't
|
||||
* translated; only the two framing strings are.
|
||||
*/
|
||||
|
||||
function Verdict({ opinion }: { opinion: LlmOpinion }) {
|
||||
return (
|
||||
<>
|
||||
<strong>{opinion.category}</strong>
|
||||
{opinion.confidence && <span className="hint">{` · ${opinion.confidence}`}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** In the message details, beside the spam filter's own working. */
|
||||
export function LlmOpinionDetail({ opinion }: { opinion: LlmOpinion }) {
|
||||
return (
|
||||
<div className="llm-opinion">
|
||||
<div>
|
||||
<Verdict opinion={opinion} />
|
||||
</div>
|
||||
{opinion.explanation && <div className="llm-explanation">{opinion.explanation}</div>}
|
||||
<div className="hint">{translate("One of several signals the spam filter weighed")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Above a message that's in Junk. */
|
||||
export function LlmOpinionBanner({ opinion }: { opinion: LlmOpinion }) {
|
||||
return (
|
||||
<div className="remote-banner llm-banner" role="note" style={{ margin: "0 16px 8px" }}>
|
||||
<Bot size={16} />
|
||||
<span className="grow llm-opinion">
|
||||
<span className="llm-heading">
|
||||
<span>{translate("Language model's opinion")}</span>
|
||||
<span>
|
||||
<Verdict opinion={opinion} />
|
||||
</span>
|
||||
</span>
|
||||
{opinion.explanation && <span className="llm-explanation">{opinion.explanation}</span>}
|
||||
<span className="hint">{translate("One of several signals the spam filter weighed")}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The banner shows only for a message in Junk that carries an opinion. */
|
||||
export function llmBannerOpinion(
|
||||
opinion: LlmOpinion | null,
|
||||
mailboxIds: Record<string, boolean>,
|
||||
junkId: string | null | undefined,
|
||||
): LlmOpinion | null {
|
||||
return opinion && junkId && mailboxIds[junkId] ? opinion : null;
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import { emlFilename } from "@/lib/text/emlName";
|
||||
import { isTnef, parseTnef, type TnefAttachment } from "@/lib/tnef";
|
||||
import { internalDomains, isExternalSender, linkVerdict } from "@/lib/warnings";
|
||||
import { spamReport, type SpamReport } from "@/lib/spamScore";
|
||||
import { llmOpinion } from "@/lib/llmOpinion";
|
||||
import { LlmOpinionBanner, LlmOpinionDetail, llmBannerOpinion } from "./LlmOpinion";
|
||||
import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
|
||||
import { displayName, domainOf, formatAddress } from "@/lib/address";
|
||||
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, hasHtmlAlternative, htmlDeclaresColors, markKeptSurfaces, sanitizeEmailHtml } from "@/lib/text/html";
|
||||
@@ -187,6 +189,10 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
const receiptRequested = Boolean(e["header:Disposition-Notification-To:asAddresses"]?.length);
|
||||
const authFailed = /\b(dkim|spf|dmarc)=fail\b/i.test(e["header:Authentication-Results:asText"] ?? "");
|
||||
const spam = useMemo(() => spamReport(e), [e]);
|
||||
// inbuxa: the language model's opinion, where the server's AI spam classification wrote one
|
||||
const llm = useMemo(() => llmOpinion(e), [e]);
|
||||
const junkId = useMail((st) => st.roleId("junk"));
|
||||
const llmBanner = llmBannerOpinion(llm, e.mailboxIds, junkId);
|
||||
const identities = useMail((st) => st.identities);
|
||||
/*
|
||||
* Only computed when the warning is on, because the domains it compares
|
||||
@@ -374,6 +380,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
{e["header:List-Id:asText"] && <><dt>{translate("List")}</dt><dd>{e["header:List-Id:asText"]}</dd></>}
|
||||
<dt>{translate("Size")}</dt><dd>{formatSize(e.size)}</dd>
|
||||
{spam && <><dt>{translate("Spam filter")}</dt><dd><SpamSummary report={spam} /></dd></>}
|
||||
{llm && <><dt>{translate("Language model's opinion")}</dt><dd><LlmOpinionDetail opinion={llm} /></dd></>}
|
||||
{receiptRequested && <><dt>{translate("Receipt")}</dt><dd>{receipt.offer ? translate("Requested, to {address}. Never sent automatically.", { address: receipt.to!.email }) : translate(refusalText(receipt.refusal!))}</dd></>}
|
||||
</dl>
|
||||
)}
|
||||
@@ -425,6 +432,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
</div>
|
||||
)}
|
||||
<SignatureBanner state={signature} />
|
||||
{llmBanner && <LlmOpinionBanner opinion={llmBanner} />}
|
||||
{externalSender && (
|
||||
<div className="remote-banner external-banner" style={{ margin: "0 16px 8px" }}>
|
||||
<ShieldAlert size={16} />
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { LlmOpinionBanner, LlmOpinionDetail, llmBannerOpinion } from "../LlmOpinion";
|
||||
import { parseLlmOpinion, type LlmOpinion } from "@/lib/llmOpinion";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
/*
|
||||
* The framing is the feature: the model's opinion is always one signal among
|
||||
* several, never presented as why a message is where it is, and its
|
||||
* explanation is model output, so it must never be rendered as markup.
|
||||
*/
|
||||
const opinion = (raw: string) => parseLlmOpinion(raw) as LlmOpinion;
|
||||
|
||||
describe("the language model's opinion", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const render = async (node: React.ReactNode) => {
|
||||
await act(async () => {
|
||||
root.render(node);
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("shows category, confidence and explanation, as one signal of several", async () => {
|
||||
await render(<LlmOpinionDetail opinion={opinion("LLM_UNSOLICITED_HIGH (Sells something unasked)")} />);
|
||||
expect(host.textContent).toContain("Unsolicited");
|
||||
expect(host.textContent).toContain("High");
|
||||
expect(host.textContent).toContain("Sells something unasked");
|
||||
expect(host.textContent).toContain("One of several signals the spam filter weighed");
|
||||
});
|
||||
|
||||
it("renders the explanation as text, never markup", async () => {
|
||||
await render(<LlmOpinionDetail opinion={opinion('LLM_HARMFUL_HIGH (<img src=x onerror="alert(1)"> <b>bold</b>)')} />);
|
||||
expect(host.querySelector("img")).toBeNull();
|
||||
expect(host.querySelector("b")).toBeNull();
|
||||
expect(host.textContent).toContain('<img src=x onerror="alert(1)">');
|
||||
});
|
||||
|
||||
it("leaves out what the header didn't carry", async () => {
|
||||
await render(<LlmOpinionDetail opinion={opinion("LLM_LEGITIMATE")} />);
|
||||
expect(host.querySelector(".llm-explanation")).toBeNull();
|
||||
expect(host.textContent).not.toContain("·");
|
||||
});
|
||||
|
||||
it("banners a message in Junk with the same framing", async () => {
|
||||
await render(<LlmOpinionBanner opinion={opinion("LLM_UNSOLICITED_MEDIUM (Bulk newsletter)")} />);
|
||||
expect(host.textContent).toContain("Language model's opinion");
|
||||
expect(host.textContent).toContain("Unsolicited");
|
||||
expect(host.textContent).toContain("Bulk newsletter");
|
||||
expect(host.textContent).toContain("One of several signals the spam filter weighed");
|
||||
});
|
||||
|
||||
it("banners only a message that's in Junk and carries an opinion", () => {
|
||||
const o = opinion("LLM_UNSOLICITED_HIGH");
|
||||
expect(llmBannerOpinion(o, { junk1: true }, "junk1")).toBe(o);
|
||||
expect(llmBannerOpinion(o, { inbox1: true }, "junk1")).toBeNull();
|
||||
expect(llmBannerOpinion(o, { junk1: true }, null)).toBeNull();
|
||||
expect(llmBannerOpinion(null, { junk1: true }, "junk1")).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user