mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
Grade the Coach turn that went looking and found nothing
The numeric grounding guard keyed its activation off `loop.toolResults` alone.
A tool that finds nothing resolves to `{ present: false, reason: "none" }` with
no `available` payload, `runCoachToolLoop` drops that shape, and the no-tools
snapshot fallback is populated only in the non-tool branch — so the guard sat
out every turn where the record was empty, which is precisely the turn where a
fabricated figure is likeliest and does the most harm. Ask about a metric you
have never logged and an invented number was persisted and streamed verbatim,
with no withheld-figure marker to say it had never been checked.
`toolTrace` is the discriminant, because it records the pure misses the results
array drops. Tools ran and every one missed: grade the reply — an empty ledger
there is not missing information, it is the positive statement that nothing can
be cited, so `findUnverifiedCoachNumbersInLedger` takes an explicit opt-in to
grade against it rather than no-opping. The model answered without calling a
tool: still dormant, unchanged, because the tool-mode base prompt carries no
pre-computed figures and flagging a legitimately recalled one was the v1.32.1
regression. The inventory counts ride along as a widener so a plain count
restatement stays grounded.
Eliding the digits is not enough on an empty record. "Your sleep averaged […]
minutes, up from […]" still asserts a series, an average and a direction for a
metric with no readings; only the precision goes, the false claim stays. So a
turn that missed every tool and lost a figure to the guard is replaced with the
honest answer, in all six locales. Scoped so a reply whose figure reconciles
against a prior turn or an inventory count is left alone.
This commit is contained in:
1 parent
d49479bc64
commit
251a42bb0a
9 files changed
+185
-40
No files matched your search
+2
-1
@@ -8967,7 +8967,8 @@
|
||||
"outOfScope": "Ich kann nur bei den Gesundheitsdaten in deinem Log helfen — Messwerte, Medikamente, Stimmung, Trends. Fragen außerhalb dieses Themas beantworte ich nicht.",
|
||||
"promptInjection": "In deiner Nachricht stehen Anweisungen, die meine Vorgaben überschreiben sollen. Ich bin der HealthLog-Coach und fasse ausschließlich deine eigenen Gesundheitsdaten zusammen — formuliere deine Frage bitte in diesem Rahmen neu.",
|
||||
"conversationPoisoned": "Eine frühere Nachricht in dieser Unterhaltung enthält Anweisungen, die meine Vorgaben überschreiben sollen. Beginne bitte eine neue Unterhaltung."
|
||||
}
|
||||
},
|
||||
"noRecordedData": "Zu deiner Frage sind bei dir keine Messwerte erfasst, also kann ich dir dazu keine Zahl nennen. Sobald du etwas eingetragen hast, frag mich noch einmal — dann rechne ich mit deinen eigenen Werten."
|
||||
},
|
||||
"safety": {
|
||||
"floor": {
|
||||
|
||||
+2
-1
@@ -8967,7 +8967,8 @@
|
||||
"outOfScope": "I can only help with the health metrics in your log — measurements, medications, mood, trends. I cannot answer questions outside that scope.",
|
||||
"promptInjection": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data — please rephrase your question in those terms.",
|
||||
"conversationPoisoned": "An earlier message in this conversation contains wording that overrides my instructions. Please start a new conversation."
|
||||
}
|
||||
},
|
||||
"noRecordedData": "I don't have any recorded readings for what you asked about, so there is nothing I can give you a figure from. Once you log some, ask me again and I'll work from your own numbers."
|
||||
},
|
||||
"safety": {
|
||||
"floor": {
|
||||
|
||||
+2
-1
@@ -8967,7 +8967,8 @@
|
||||
"outOfScope": "Solo puedo ayudarte con los datos de salud de tu registro: mediciones, medicamentos, estado de ánimo, tendencias. No puedo responder preguntas fuera de ese ámbito.",
|
||||
"promptInjection": "He detectado instrucciones que intentan anular mis directrices. Soy el Coach de HealthLog y solo resumo tus propios datos de salud; reformula tu pregunta en esos términos, por favor.",
|
||||
"conversationPoisoned": "Un mensaje anterior de esta conversación contiene instrucciones que intentan anular mis directrices. Empieza una conversación nueva, por favor."
|
||||
}
|
||||
},
|
||||
"noRecordedData": "No tienes ninguna medición registrada sobre lo que preguntas, así que no puedo darte ninguna cifra. Cuando registres algo, vuelve a preguntarme y trabajaré con tus propios datos."
|
||||
},
|
||||
"safety": {
|
||||
"floor": {
|
||||
|
||||
+2
-1
@@ -8967,7 +8967,8 @@
|
||||
"outOfScope": "Je peux uniquement t'aider avec les données de santé de ton journal : mesures, médicaments, humeur, tendances. Je ne réponds pas aux questions en dehors de ce cadre.",
|
||||
"promptInjection": "J'ai repéré des instructions qui cherchent à contourner mes consignes. Je suis le Coach HealthLog et je résume uniquement tes propres données de santé — reformule ta question dans ce cadre, s'il te plaît.",
|
||||
"conversationPoisoned": "Un message précédent de cette conversation contient des instructions qui cherchent à contourner mes consignes. Démarre une nouvelle conversation, s'il te plaît."
|
||||
}
|
||||
},
|
||||
"noRecordedData": "Aucune mesure n'est enregistrée pour ce que tu demandes, je ne peux donc te donner aucun chiffre. Dès que tu en auras saisi, repose-moi la question et je partirai de tes propres valeurs."
|
||||
},
|
||||
"safety": {
|
||||
"floor": {
|
||||
|
||||
+2
-1
@@ -8967,7 +8967,8 @@
|
||||
"outOfScope": "Posso aiutarti solo con i dati di salute del tuo registro: misurazioni, farmaci, umore, tendenze. Non rispondo a domande al di fuori di questo ambito.",
|
||||
"promptInjection": "Ho notato istruzioni che cercano di aggirare le mie direttive. Sono il Coach di HealthLog e riassumo soltanto i tuoi dati di salute — riformula la domanda in questi termini, per favore.",
|
||||
"conversationPoisoned": "Un messaggio precedente di questa conversazione contiene istruzioni che cercano di aggirare le mie direttive. Inizia una nuova conversazione, per favore."
|
||||
}
|
||||
},
|
||||
"noRecordedData": "Non hai misurazioni registrate su quello che chiedi, quindi non posso darti alcun valore. Appena ne inserisci qualcuna, richiedimelo e userò i tuoi dati."
|
||||
},
|
||||
"safety": {
|
||||
"floor": {
|
||||
|
||||
+2
-1
@@ -8967,7 +8967,8 @@
|
||||
"outOfScope": "Mogę pomóc wyłącznie z danymi zdrowotnymi z Twojego dziennika: pomiarami, lekami, nastrojem, trendami. Na pytania spoza tego zakresu nie odpowiadam.",
|
||||
"promptInjection": "Zauważyłem polecenia, które próbują obejść moje wytyczne. Jestem Coachem HealthLog i podsumowuję wyłącznie Twoje własne dane zdrowotne — sformułuj pytanie w tych ramach.",
|
||||
"conversationPoisoned": "Wcześniejsza wiadomość w tej rozmowie zawiera polecenia, które próbują obejść moje wytyczne. Rozpocznij nową rozmowę."
|
||||
}
|
||||
},
|
||||
"noRecordedData": "Nie masz zapisanych żadnych pomiarów dla tego, o co pytasz, więc nie mogę podać Ci żadnej liczby. Gdy coś zapiszesz, zapytaj ponownie — wtedy oprę się na Twoich własnych wartościach."
|
||||
},
|
||||
"safety": {
|
||||
"floor": {
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* CHARACTERISATION — when the Coach's numeric grounding guard is armed, and
|
||||
* when it is not.
|
||||
* REGRESSION — when the Coach's numeric grounding guard is armed, and when it
|
||||
* deliberately is not.
|
||||
*
|
||||
* The guard rewrites any figure in the reply that the turn's Grounding Ledger
|
||||
* cannot account for. It runs only when `activatingPayloads` is non-empty, and
|
||||
* on the tool path that array comes solely from `loop.toolResults`. A tool that
|
||||
* found nothing at all resolves to `{ present: false, reason: "none" }` with no
|
||||
* `available` payload, and `loop.ts` drops that shape from `toolResults`
|
||||
* (pinned in `tools-loop-miss-payloads.test.ts`). The no-tools snapshot
|
||||
* fallback is populated only in the non-tool-mode branch, so on the tool path
|
||||
* there is nothing to fall back to.
|
||||
* cannot account for. Its activation used to key solely off `loop.toolResults`,
|
||||
* and a tool that found nothing resolves to `{ present: false, reason: "none" }`
|
||||
* with no `available` payload, which `loop.ts` drops (pinned in
|
||||
* `tools-loop-miss-payloads.test.ts`). So the guard disarmed itself exactly
|
||||
* when the record was empty, and a fabricated figure shipped verbatim with no
|
||||
* withheld-figure marker.
|
||||
*
|
||||
* The consequence, pinned below: on a tool-mode turn where every tool missed —
|
||||
* i.e. exactly when the user has no data for what they asked about — a figure
|
||||
* the model invented is streamed and persisted verbatim, and the reply carries
|
||||
* no `unverifiedFigures` marker, so it reads to the user as fully checked.
|
||||
* `toolTrace` now discriminates the two cases the old condition conflated:
|
||||
*
|
||||
* These tests assert the CURRENT behaviour, not the desired one. If the
|
||||
* activation gate is widened so the guard covers the all-missed turn, they will
|
||||
* fail — that failure is the fix landing, and the assertions should be flipped
|
||||
* to the safe expectation at that point.
|
||||
*
|
||||
* The first test is the control: with one present tool result the guard is
|
||||
* armed and the same invented figure is stripped.
|
||||
* - tools RAN and every one missed -> grade. An empty ledger is the finding,
|
||||
* not a reason to skip, so every magnitude is unreconciled. A reply that
|
||||
* loses a figure this way is replaced with the honest "nothing recorded"
|
||||
* answer rather than shipping elided prose that still asserts a series.
|
||||
* - the model called NO tool -> stay dormant. This is the v1.32.1
|
||||
* contract: the tool-mode base prompt deliberately carries no pre-computed
|
||||
* figures, and flagging a legitimately recalled one was a real regression.
|
||||
*/
|
||||
|
||||
const SNAPSHOT_JSON = '{"bp":{"aggregate":{"mean":128}}}';
|
||||
@@ -301,6 +297,27 @@ describe("coach chat — grounding-guard activation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** Drive the loop with an explicit trace so a MISS is modelled faithfully. */
|
||||
function stubMissedTurn(prose: string): void {
|
||||
parseKeyValuesSentinel.mockReturnValue({
|
||||
prose,
|
||||
keyValues: [],
|
||||
malformed: false,
|
||||
malformedEntries: [],
|
||||
});
|
||||
parseSuggestReminder.mockReturnValue({ prose });
|
||||
runCoachToolLoop.mockImplementation(async () => ({
|
||||
result: { content: prose, tokensUsed: 80, model: "m" },
|
||||
workingProviderType: "anthropic",
|
||||
totalTokens: 80,
|
||||
rounds: 1,
|
||||
// The tool ran and found nothing; `loop.ts` drops the pure miss from
|
||||
// `toolResults` but the trace still records the call.
|
||||
toolTrace: [{ name: "get_sleep", present: false }],
|
||||
toolResults: [],
|
||||
}));
|
||||
}
|
||||
|
||||
it("CONTROL — one present tool result arms the guard: the invented figure is stripped", async () => {
|
||||
stubReply("Your sleep averaged 432 minutes last week.", [
|
||||
{ present: true, data: { metric: "bp", aggregate: { avgSys30: 128 } } },
|
||||
@@ -314,22 +331,58 @@ describe("coach chat — grounding-guard activation", () => {
|
||||
expect(assistantProvenance()?.unverifiedFigures).toBe(1);
|
||||
});
|
||||
|
||||
it("every tool missed: the guard stays dormant and the invented figure survives", async () => {
|
||||
// A pure miss never reaches `toolResults` (see tools-loop-miss-payloads),
|
||||
// so this is what the route sees when the record holds nothing.
|
||||
stubReply("Your sleep averaged 432 minutes last week.", []);
|
||||
it("every tool missed: the fabricated figure is caught and the turn is replaced", async () => {
|
||||
stubMissedTurn("Your sleep averaged 432 minutes last week.");
|
||||
|
||||
await post(chatReq({ conversationId: "c1", message: "how did I sleep?" }));
|
||||
await sse.done;
|
||||
|
||||
expect(assistantContent()).toBe(
|
||||
"Your sleep averaged 432 minutes last week.",
|
||||
);
|
||||
// No withheld-figure notice either, so the reply reads as verified.
|
||||
const content = assistantContent();
|
||||
expect(content).not.toContain("432");
|
||||
// Not the elided prose either — the sentence asserted a series the record
|
||||
// does not hold, so the whole turn is replaced with the honest answer.
|
||||
expect(content).not.toContain("[…]");
|
||||
expect(content).toContain("recorded readings");
|
||||
// The replacement copy says it in words, so the count-based notice is off.
|
||||
expect(assistantProvenance()?.unverifiedFigures).toBeUndefined();
|
||||
});
|
||||
|
||||
it("the model called no tool at all: the guard stays dormant", async () => {
|
||||
it("every tool missed but the figure is grounded by a prior turn: left alone", async () => {
|
||||
// Turn 1 fetched systolic 128 and persisted it as a tool figure. This turn
|
||||
// every tool misses, but recalling 128 still reconciles, so nothing is
|
||||
// stripped and the reply is NOT replaced.
|
||||
fetchConversationWithMessages.mockResolvedValue({
|
||||
id: "c1",
|
||||
attachmentCount: 0,
|
||||
summary: null,
|
||||
messages: [
|
||||
{ role: "user", content: "How is my BP?" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Your systolic averaged 128.",
|
||||
metricSource: { groundedFigures: [128] },
|
||||
},
|
||||
],
|
||||
});
|
||||
stubMissedTurn(
|
||||
"No sleep readings yet. Your systolic 128 average is unrelated to that.",
|
||||
);
|
||||
|
||||
await post(chatReq({ conversationId: "c1", message: "and my sleep?" }));
|
||||
await sse.done;
|
||||
|
||||
const content = assistantContent();
|
||||
expect(content).toContain("128");
|
||||
expect(content).not.toContain("[…]");
|
||||
expect(content).not.toContain("recorded readings");
|
||||
});
|
||||
|
||||
it("the model called no tool at all: the guard stays dormant (v1.32.1)", async () => {
|
||||
// Deliberately unchanged behaviour. The tool-mode base prompt carries no
|
||||
// pre-computed figures, so a number here came from the transcript or the
|
||||
// model's own recall, and grading it off the counts-only inventory flagged
|
||||
// legitimate figures as ungrounded. `toolTrace` is empty, so the widened
|
||||
// activation above does not reach this case.
|
||||
const prose = "Your resting heart rate has been averaging 58 bpm.";
|
||||
parseKeyValuesSentinel.mockReturnValue({
|
||||
prose,
|
||||
|
||||
@@ -639,6 +639,13 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
// delivered this turn (`includeFullSnapshot`); on a cheap follow-up the block
|
||||
// was not re-sent, so there is no fresh authoritative set to grade against.
|
||||
let noToolsSnapshotPayloads: unknown[] = [];
|
||||
// The DATA INVENTORY manifest (per-domain sample counts) that rode this
|
||||
// turn's tool-mode prompt. Hoisted out of the tool branch because the
|
||||
// all-missed activation below needs it as a WIDENER: the counts were in
|
||||
// front of the model, so a plain count restatement ("you've logged 42 BP
|
||||
// readings") must stay grounded even on a turn whose every tool missed.
|
||||
// Never an ACTIVATOR on its own — see the v1.32.1 note below.
|
||||
let inventoryPayloads: unknown[] = [];
|
||||
let totalTokensSpent: number;
|
||||
// v1.21.0 (F3) — cached-input tokens to subtract at reconcile (prompt-cached
|
||||
// input the user did not re-pay for must not be billed to the daily meter).
|
||||
@@ -705,6 +712,7 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
// out-of-window aggregate rule 3 lets the model cite. Ground it.
|
||||
...(loop.toolResults ?? []).map((r) => r.data ?? r.available),
|
||||
];
|
||||
inventoryPayloads = [inventory.entries];
|
||||
toolResultPayloads =
|
||||
presentToolPayloads.length > 0
|
||||
? [...presentToolPayloads, inventory.entries]
|
||||
@@ -993,17 +1001,43 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
// active grading — they never ACTIVATE it, so a snapshot figure the model
|
||||
// cited on a no-tool turn is still left alone (the v1.32.1 regression guard
|
||||
// holds). Assistant prose is never a ledger source (D3).
|
||||
//
|
||||
// The turn that CALLED tools and got nothing back is graded too. It used to
|
||||
// be the one turn the verifier sat out: a pure miss carries no `data` and no
|
||||
// `available`, `runCoachToolLoop` drops that shape from `toolResults`, and
|
||||
// the no-tools snapshot fallback below is populated only in the non-tool
|
||||
// branch — so `activatingPayloads` came out empty and every figure in the
|
||||
// reply shipped unchecked. That is backwards. A turn whose every tool
|
||||
// reported `{ present: false }` is not an absence of evidence about the
|
||||
// reply, it is evidence that the record holds nothing to cite, which is
|
||||
// exactly when a fabricated figure is both likeliest and most harmful.
|
||||
//
|
||||
// `toolTrace` is the discriminant, because it records every tool that ran
|
||||
// INCLUDING the pure misses. It separates the two cases the old condition
|
||||
// conflated: tools ran and found nothing (grade — the model was told the
|
||||
// record is empty and answered with numbers anyway), versus the model
|
||||
// answered without calling a tool at all (stay dormant — the v1.32.1
|
||||
// regression, where the base prompt deliberately carries no pre-computed
|
||||
// figures and flagging a legitimately recalled one was a real defect).
|
||||
const missedEveryTool =
|
||||
toolMode && toolTrace.length > 0 && toolResultPayloads.length === 0;
|
||||
const activatingPayloads =
|
||||
toolResultPayloads.length > 0
|
||||
? toolResultPayloads
|
||||
: noToolsSnapshotPayloads;
|
||||
: missedEveryTool
|
||||
? inventoryPayloads
|
||||
: noToolsSnapshotPayloads;
|
||||
let groundedFigures: number[] = [];
|
||||
// v1.32.14 — count of figures withheld from THIS reply (each rewritten to the
|
||||
// elision mark). Hoisted out of the guard block so it can ride the provenance
|
||||
// envelope and drive the quiet per-message notice. Stays 0 on a blocked turn
|
||||
// (the guard is skipped, its fallback prose carries no figures).
|
||||
let unverifiedStripped = 0;
|
||||
if (activatingPayloads.length > 0) {
|
||||
// True when the guard stripped a figure on a turn whose every tool missed —
|
||||
// i.e. the model put a number on a record that holds none. Drives the
|
||||
// honest-replacement below.
|
||||
let fabricatedOnEmptyRecord = false;
|
||||
if (activatingPayloads.length > 0 || missedEveryTool) {
|
||||
const ledger = buildGroundingLedger({
|
||||
toolPayloads: activatingPayloads,
|
||||
priorToolFigures,
|
||||
@@ -1026,6 +1060,9 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
replyText,
|
||||
ledger,
|
||||
locale,
|
||||
// On an all-missed turn an EMPTY ledger is the finding, not a reason
|
||||
// to skip: nothing was retrievable, so nothing can reconcile.
|
||||
{ gradeAgainstEmptyLedger: missedEveryTool },
|
||||
);
|
||||
if (unverified.length > 0) {
|
||||
const { prose: corrected, stripped } = stripUnverifiedNumbers(
|
||||
@@ -1034,6 +1071,7 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
);
|
||||
replyText = corrected;
|
||||
unverifiedStripped = stripped;
|
||||
fabricatedOnEmptyRecord = missedEveryTool && stripped > 0;
|
||||
annotate({
|
||||
action: { name: "coach.prose.number_unverified" },
|
||||
meta: {
|
||||
@@ -1048,6 +1086,42 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
// A stripped reply on an empty record is not worth sending. Eliding the
|
||||
// digits leaves the sentences that carried them — "your sleep averaged […]
|
||||
// minutes, up from […]" still asserts a series, an average and a direction
|
||||
// for a metric the tools just reported has no readings at all. The false
|
||||
// claim survives the strip; only its precision goes. So replace the turn
|
||||
// with the honest answer instead of shipping the elided mush.
|
||||
//
|
||||
// Scoped tightly: this fires only when every tool missed AND the guard
|
||||
// actually stripped something. A reply that cited a legitimately grounded
|
||||
// figure (a prior turn's tool result, an inventory count) reconciles
|
||||
// against the ledger, strips nothing, and is left alone.
|
||||
//
|
||||
// REPLACE, not withhold — the same policy the outbound screen uses on this
|
||||
// surface, for the same reason: the user is waiting on a synchronous answer
|
||||
// and silence reads as a failure.
|
||||
//
|
||||
// Accepted trade-off: a reply mixing one grounded recall with one
|
||||
// fabrication is replaced wholesale, losing the good half. That is the
|
||||
// right way round — the alternative leaves an invented figure's sentence
|
||||
// on screen.
|
||||
if (fabricatedOnEmptyRecord) {
|
||||
replyText = getServerTranslator(locale).t("coach.noRecordedData");
|
||||
// The reply now carries no figures at all, so the withheld-figure notice
|
||||
// would be describing prose the reader can no longer see. The replacement
|
||||
// copy states the same thing in plain words.
|
||||
unverifiedStripped = 0;
|
||||
annotate({
|
||||
action: { name: "coach.prose.empty_record_replaced" },
|
||||
meta: { promptVersion: PROMPT_VERSION },
|
||||
});
|
||||
await auditLog("insights.coach.empty_record_replaced", {
|
||||
userId,
|
||||
details: { conversationId: workingConversationId },
|
||||
});
|
||||
}
|
||||
|
||||
// v1.21.0 (NEW-C C-3) — Learn-link post-filter. The prompt instructs the
|
||||
// model to only link a published `/learn/<slug>`, but that is guidance, not
|
||||
// enforcement: a fabricated `/learn/<invented-slug>` would otherwise ship as
|
||||
|
||||
@@ -739,11 +739,23 @@ export function findUnverifiedCoachNumbersInLedger(
|
||||
prose: string,
|
||||
ledger: ReadonlyArray<LedgerEntry>,
|
||||
locale: Locale = "en",
|
||||
opts: { gradeAgainstEmptyLedger?: boolean } = {},
|
||||
): UnverifiedCoachNumber[] {
|
||||
if (!prose) return [];
|
||||
// No authoritative figures (a qualitative turn / no-tools path) — nothing to
|
||||
// grade against. The prompt-level grounding rule remains the backstop.
|
||||
if (ledger.length === 0) return [];
|
||||
// An empty ledger is ambiguous on its own, so the caller resolves it.
|
||||
//
|
||||
// Default (`false`): no authoritative figures were delivered — a qualitative
|
||||
// turn, or the no-tools path on a cheap follow-up. Nothing to grade against,
|
||||
// and the prompt-level grounding rule remains the backstop.
|
||||
//
|
||||
// `gradeAgainstEmptyLedger: true`: the caller KNOWS this turn went looking
|
||||
// and came back with nothing — every retrieval tool it called reported
|
||||
// `{ present: false }`. Then an empty ledger is not "no information", it is
|
||||
// the positive statement that the record holds nothing to cite, so every
|
||||
// magnitude in the reply is unreconciled by construction and must be graded
|
||||
// as such. Only the chat route passes this; the fenced-chat and eval callers
|
||||
// keep the default.
|
||||
if (ledger.length === 0 && !opts.gradeAgainstEmptyLedger) return [];
|
||||
|
||||
const spans = sentenceSpans(prose);
|
||||
const sentenceAt = (index: number): string => {
|
||||
|
||||
Reference in new issue
Block a user