mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
feat(coach): give the turn the memory block, its recalled facts and a pending proposal
Build the memory block for the providers the turn sends to, send it on both paths, keep the facts it recalled in the owner's trail, and offer a health fact the background found as the answer's one note. Every turn enqueues the memory refresh; the job's debounce and singleton key decide when it runs. A remembered fact points back to the stored user message.
This commit is contained in:
1 parent
f46e0960fb
commit
de5b315999
10 files changed
+176
-53
No files matched your search
@@ -31,6 +31,8 @@ import {
|
||||
import {
|
||||
PLAN_REVIEW_DAYS,
|
||||
REMEMBER_FACT_MAX_CHARS,
|
||||
} from "@/lib/ai/coach/memory/shared";
|
||||
import {
|
||||
proposePlanFromTool,
|
||||
rememberFactFromTool,
|
||||
} from "@/lib/ai/coach/memory/contract";
|
||||
@@ -200,6 +202,8 @@ export interface DialogToolContext {
|
||||
locale: Locale;
|
||||
/** The person's current message: the only text a fact may come from. */
|
||||
userMessage: string;
|
||||
/** The stored id of that message, which a remembered fact points back to. */
|
||||
userMessageId?: string;
|
||||
/** What the record holds; null on the no-tools path. */
|
||||
inventory: InventoryEntry[] | null;
|
||||
/**
|
||||
@@ -319,6 +323,7 @@ export async function runDialogTool(args: {
|
||||
userId: ctx.userId,
|
||||
conversationId: ctx.conversationId,
|
||||
userMessage: ctx.userMessage,
|
||||
...(ctx.userMessageId ? { userMessageId: ctx.userMessageId } : {}),
|
||||
call: parsed.data,
|
||||
});
|
||||
if (outcome.kind === "declined") {
|
||||
|
||||
@@ -228,6 +228,12 @@ export async function runCoachToolLoop(args: {
|
||||
locale?: Locale;
|
||||
/** v1.41 — what the dialog tools need. Absent: they are declined. */
|
||||
dialog?: DialogToolContext;
|
||||
/**
|
||||
* v1.41 — a note the answer already carries before any round (a health
|
||||
* fact the background found, offered once). It is the answer's one note:
|
||||
* `remember_fact` is declined beside it.
|
||||
*/
|
||||
initialMemoryNote?: CoachMemoryNote;
|
||||
/** v1.41 — the figures read so far, for screening a checkpoint. */
|
||||
checkpointScreen?: { userMessage: string };
|
||||
}): Promise<CoachToolLoopResult> {
|
||||
@@ -276,7 +282,7 @@ export async function runCoachToolLoop(args: {
|
||||
const toolTrace: CoachToolTrace[] = [];
|
||||
const toolResults: CoachToolResult[] = [];
|
||||
const declinedClarifications: CoachClarification[] = [];
|
||||
let memoryNote: CoachMemoryNote | undefined;
|
||||
let memoryNote: CoachMemoryNote | undefined = args.initialMemoryNote;
|
||||
let planProposal: CoachPlanProposal | undefined;
|
||||
let callCount = 0;
|
||||
let stopReason: CoachStopReason | null = null;
|
||||
|
||||
@@ -131,6 +131,7 @@ function persistedProvenance(): CoachProvenance {
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(m)) fn.mockReset();
|
||||
m.persistUserTurn.mockResolvedValue({ messageId: "m-user" });
|
||||
m.resolveTurnConversation.mockResolvedValue({
|
||||
conversation: {
|
||||
conversationId: "c1",
|
||||
|
||||
@@ -238,6 +238,7 @@ function modelPath(prefs = DEFAULT_COACH_PREFS) {
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(m)) fn.mockReset();
|
||||
m.persistUserTurn.mockResolvedValue({ messageId: "m-user" });
|
||||
m.readFollowUpHistory.mockResolvedValue({
|
||||
today: "2026-09-27",
|
||||
firstDate: { bp: "2024-01-01" },
|
||||
|
||||
@@ -177,18 +177,19 @@ export async function assembleTurnContext(args: {
|
||||
// Fact capture must never break the chat turn; the >TURN_CAP LLM
|
||||
// extraction remains as the catch-all on long conversations.
|
||||
});
|
||||
if (turnContext.historyElided) {
|
||||
void enqueueCoachMemoryRefresh({
|
||||
conversationId,
|
||||
userId,
|
||||
// Coach memory prose is composed in de/en only — it is MODEL-FACING
|
||||
// context (a rolling conversation summary + extracted durable facts),
|
||||
// not user-facing prose, so English is the correct target for every
|
||||
// locale without a reviewed body. The former `=== "en" ? "en" : "de"`
|
||||
// binary composed and keyed a French account's memory in German.
|
||||
locale: instructionLocale(locale),
|
||||
});
|
||||
}
|
||||
// v1.41 — every turn enqueues the refresh; the job waits for the
|
||||
// conversation to go quiet and runs once per conversation (its debounce
|
||||
// and singleton key), so a short chat is remembered too.
|
||||
void enqueueCoachMemoryRefresh({
|
||||
conversationId,
|
||||
userId,
|
||||
// Coach memory prose is composed in de/en only — it is MODEL-FACING
|
||||
// context (a rolling conversation summary + extracted durable facts),
|
||||
// not user-facing prose, so English is the correct target for every
|
||||
// locale without a reviewed body. The former `=== "en" ? "en" : "de"`
|
||||
// binary composed and keyed a French account's memory in German.
|
||||
locale: instructionLocale(locale),
|
||||
});
|
||||
|
||||
// A workout launch is an optional narrowing of Coach, not permission to
|
||||
// bypass the workouts module. Disabled modules contribute no read and no
|
||||
|
||||
@@ -163,10 +163,12 @@ export async function resolveTurnConversation(args: {
|
||||
export async function persistUserTurn(
|
||||
conversationId: string,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
await appendMessage({
|
||||
): Promise<{ messageId: string }> {
|
||||
const stored = await appendMessage({
|
||||
conversationId,
|
||||
role: "user",
|
||||
content: message,
|
||||
});
|
||||
// v1.41 — the id a remembered fact points back to.
|
||||
return { messageId: stored.id };
|
||||
}
|
||||
@@ -282,6 +282,8 @@ export async function runTurnModel(args: {
|
||||
priorResults?: PriorResultTurn[];
|
||||
/** v1.41 — the person's message, the only source of a remembered fact. */
|
||||
message: string;
|
||||
/** v1.41 — that message's stored id. */
|
||||
userMessageId?: string;
|
||||
/** v1.41 — the reasoning the turn asks for, already resolved. */
|
||||
reasoning?: TurnReasoning;
|
||||
/** v1.41 — who pays: fixes the turn's budget. */
|
||||
@@ -333,8 +335,13 @@ export async function runTurnModel(args: {
|
||||
userId,
|
||||
conversationId,
|
||||
locale,
|
||||
// The block re-runs the wire egress check for exactly these.
|
||||
providerTypes: chain.map((entry) => entry.providerType),
|
||||
}).catch(() => null);
|
||||
if (memory) {
|
||||
recorder.setRecalled(memory.recalled);
|
||||
}
|
||||
if (memory && memory.factIds.length + memory.planIds.length > 0) {
|
||||
const count = memory.factIds.length + memory.planIds.length;
|
||||
const id = activity.start({
|
||||
phase: "memory",
|
||||
@@ -365,12 +372,8 @@ export async function runTurnModel(args: {
|
||||
reach: ctx.reach,
|
||||
prior: args.priorResults ?? [],
|
||||
});
|
||||
const requestWith = (tableRules: boolean) => {
|
||||
// `memoryBlock` is the memory package's parameter; held in a
|
||||
// variable so the request builds whether or not it is declared yet.
|
||||
const request: Parameters<typeof buildCoachToolRequest>[0] & {
|
||||
memoryBlock?: string;
|
||||
} = {
|
||||
const requestWith = (tableRules: boolean) =>
|
||||
buildCoachToolRequest({
|
||||
systemPrompt: ctx.systemPrompt,
|
||||
toolModeAddendum: appendBlocks(buildToolModeAddendum(locale), [
|
||||
buildDialogAddenda(locale, { tableRules }),
|
||||
@@ -384,9 +387,7 @@ export async function runTurnModel(args: {
|
||||
transcript: turnContext.transcript,
|
||||
languageName: LANGUAGE_NAMES[locale],
|
||||
...(memory ? { memoryBlock: memory.text } : {}),
|
||||
};
|
||||
return buildCoachToolRequest(request);
|
||||
};
|
||||
});
|
||||
// The recheck and follow-up rules are about tables: they ride the
|
||||
// prompt once the conversation holds one, or from the round after this
|
||||
// turn produced its first.
|
||||
@@ -445,11 +446,15 @@ export async function runTurnModel(args: {
|
||||
...(args.reasoning ? { reasoning: args.reasoning } : {}),
|
||||
activity,
|
||||
locale,
|
||||
...(memory?.pendingProposal
|
||||
? { initialMemoryNote: memory.pendingProposal }
|
||||
: {}),
|
||||
dialog: {
|
||||
userId,
|
||||
conversationId,
|
||||
locale,
|
||||
userMessage: args.message,
|
||||
...(args.userMessageId ? { userMessageId: args.userMessageId } : {}),
|
||||
inventory: inventory.entries,
|
||||
conversationWindowSet: args.conversationWindowSet,
|
||||
},
|
||||
@@ -698,7 +703,7 @@ export async function runTurnModel(args: {
|
||||
activity: recorder,
|
||||
toolClarification: null,
|
||||
declinedClarifications: [],
|
||||
memoryNote: null,
|
||||
memoryNote: memory?.pendingProposal ?? null,
|
||||
planProposal: null,
|
||||
interimSent: false,
|
||||
};
|
||||
|
||||
@@ -149,7 +149,10 @@ async function runTurn(
|
||||
...(clarifiedLine ? [clarifiedLine] : []),
|
||||
];
|
||||
|
||||
await persistUserTurn(workingConversationId, message);
|
||||
const { messageId: userMessageId } = await persistUserTurn(
|
||||
workingConversationId,
|
||||
message,
|
||||
);
|
||||
|
||||
const ctx = await assembleTurnContext({
|
||||
userId,
|
||||
@@ -219,6 +222,7 @@ async function runTurn(
|
||||
turnHints,
|
||||
priorResults: conversation.priorResults,
|
||||
message,
|
||||
userMessageId,
|
||||
reasoning,
|
||||
payer,
|
||||
conversationWindowSet: input.scope?.window !== undefined,
|
||||
|
||||
@@ -49,6 +49,8 @@ import { createSseStream } from "@/lib/sse/create-stream";
|
||||
import {
|
||||
COACH_MEMORY_KEYS,
|
||||
COACH_PLAN_KEYS,
|
||||
} from "@/lib/ai/coach/memory/shared";
|
||||
import {
|
||||
decideFactProposal,
|
||||
decidePlanProposal,
|
||||
} from "@/lib/ai/coach/memory/contract";
|
||||
|
||||
Reference in new issue
Block a user