mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
refactor(insights): wire the WMY rollup tier into status snapshots and share generator helpers
Read the MONTH / YEAR rollup tier for the monthly / yearly slices of the
focused single-metric status snapshots (weight, pulse, bmi,
blood-pressure) via `buildGradedSeriesWithRollups`, so the coarse tiers
finally earn their write amplification. BMI has no tier of its own, so it
scales the WEIGHT tier by 1 / heightCm² (`scaleGradedSeries`) — an exact
linear transform. The multi-metric general overview and the tier-less
mood / adherence series keep their bounded in-memory fold.
Fix the coverage-miss path that returned an always-empty monthly / yearly
slice: the bounded ~90-day recent read can never supply those slices, so
on a cold-tier miss the builder now folds the full history in memory
rather than shipping empty coarse arrays when years of raw history exist.
Excludes tombstoned rows from both reads.
Lift the byte-identical `round`, `normalizeSummaryText`,
`normalizeLocale`, `summarizeSeries`, the `{summary}`-envelope parse, and
the cache-row persist block out of the seven generators into
`status-shared.ts`. Drop the per-medication expectation in the compliance
generator: the prompt returns only `{summary}`, so the per-med cards are
server-side placeholders rather than a dead model-authored branch. Remove
the unused `summarizeGradedSeries` export.
Behaviour is unchanged on the in-memory path; the rollup path is exact
where the tier composes (count / min / max / mean) and the payload stays
under the snapshot char cap.
This commit is contained in:
1 parent
75380d5f39
commit
eee6ab2e7e
17 files changed
+580
-587
No files matched your search
@@ -5,6 +5,7 @@ vi.mock("@/lib/db", () => ({
|
||||
user: { findUnique: vi.fn() },
|
||||
auditLog: { findFirst: vi.fn(), create: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
measurementRollup: { findMany: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
@@ -52,6 +53,9 @@ function stubCompletion(
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({});
|
||||
// Cold rollup tier: the BP channels fold monthly/yearly from the
|
||||
// full-history `measurement.findMany` fallback on a tier miss.
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
describe("generateBloodPressureStatusForUser — graded payload", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ vi.mock("@/lib/db", () => ({
|
||||
user: { findUnique: vi.fn() },
|
||||
auditLog: { findFirst: vi.fn(), create: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
measurementRollup: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -44,6 +45,9 @@ function stubCompletion(
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// Cold rollup tier: the BMI graded series scales the WEIGHT tier, which
|
||||
// folds from the full-history `measurement.findMany` fallback on a miss.
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
describe("generateBmiStatusForUser — graded payload", () => {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const findMany = vi.fn();
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: {
|
||||
findMany: (...args: unknown[]) => findMany(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const readBestGranularityRollups = vi.fn();
|
||||
const rollupRow = (bucketStart: Date, mean: number) => ({
|
||||
bucketStart,
|
||||
count: 30,
|
||||
mean,
|
||||
sd: null,
|
||||
slope: null,
|
||||
r2: null,
|
||||
sumValue: null,
|
||||
minValue: mean - 1,
|
||||
maxValue: mean + 1,
|
||||
});
|
||||
vi.mock("@/lib/rollups/measurement-read-wmy", () => ({
|
||||
readBestGranularityRollups: (...args: unknown[]) =>
|
||||
readBestGranularityRollups(...args),
|
||||
// aggregateWmyBuckets is no longer imported by graded-series, but keep the
|
||||
// module shape intact for any transitive consumer.
|
||||
aggregateWmyBuckets: vi.fn(),
|
||||
}));
|
||||
|
||||
import { buildGradedSeriesWithRollups } from "../graded-series";
|
||||
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Daily readings spanning `days` back from `now`. */
|
||||
function dailyRows(days: number, now: Date) {
|
||||
const rows: Array<{ measuredAt: Date; value: number }> = [];
|
||||
for (let i = 0; i < days; i++) {
|
||||
rows.push({ measuredAt: new Date(now.getTime() - i * dayMs), value: 80 });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("buildGradedSeriesWithRollups — rollup-miss fallback", () => {
|
||||
const now = new Date("2026-05-31T12:00:00Z");
|
||||
|
||||
it("folds monthly/yearly from the full history when the tier has no coverage", async () => {
|
||||
// The bounded recent read (~90 d) returns only recent points; the
|
||||
// full-history read returns 2+ years so the fold has monthly/yearly.
|
||||
findMany
|
||||
// 1st call — bounded recent read (gte: since)
|
||||
.mockResolvedValueOnce(dailyRows(80, now))
|
||||
// 2nd call — full-history fallback read (no gte)
|
||||
.mockResolvedValueOnce(dailyRows(800, now));
|
||||
|
||||
// Tier miss: no MONTH / YEAR coverage at all.
|
||||
readBestGranularityRollups.mockResolvedValue(null);
|
||||
|
||||
const series = await buildGradedSeriesWithRollups("u1", "WEIGHT", now);
|
||||
|
||||
// The bug: before the fix monthly/yearly were derived from the
|
||||
// bounded ~90-day read and so were always empty even when years of
|
||||
// raw history existed. After the fix they fold from the full history.
|
||||
expect(series.monthly.length).toBeGreaterThan(0);
|
||||
// 800 days of history puts at least one year into the yearly tail.
|
||||
expect(series.yearly.length).toBeGreaterThan(0);
|
||||
// The full-history fallback read must have been issued.
|
||||
expect(findMany).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reads monthly/yearly from the tier when it has coverage and skips the full read", async () => {
|
||||
findMany.mockResolvedValueOnce(dailyRows(80, now));
|
||||
|
||||
// MONTH coverage for the 1-year window, YEAR coverage for the 3-year.
|
||||
readBestGranularityRollups.mockImplementation(
|
||||
async (_u: string, _t: string, windowDays: number) => {
|
||||
if (windowDays === 365) {
|
||||
return {
|
||||
granularity: "MONTH",
|
||||
rows: [
|
||||
rollupRow(new Date("2025-09-01T00:00:00Z"), 79),
|
||||
rollupRow(new Date("2025-10-01T00:00:00Z"), 80),
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
granularity: "YEAR",
|
||||
rows: [rollupRow(new Date("2023-01-01T00:00:00Z"), 82)],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const series = await buildGradedSeriesWithRollups("u1", "WEIGHT", now);
|
||||
|
||||
expect(series.monthly.length).toBeGreaterThan(0);
|
||||
expect(series.yearly.length).toBeGreaterThan(0);
|
||||
// Only the bounded recent read — no full-history fallback when the
|
||||
// tier covers both coarse slices.
|
||||
expect(findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to a full read for only the slice the tier misses", async () => {
|
||||
findMany
|
||||
.mockResolvedValueOnce(dailyRows(80, now)) // bounded recent
|
||||
.mockResolvedValueOnce(dailyRows(800, now)); // full-history fallback
|
||||
|
||||
// MONTH covered, YEAR missing.
|
||||
readBestGranularityRollups.mockImplementation(
|
||||
async (_u: string, _t: string, windowDays: number) => {
|
||||
if (windowDays === 365) {
|
||||
return {
|
||||
granularity: "MONTH",
|
||||
rows: [rollupRow(new Date("2025-10-01T00:00:00Z"), 80)],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
const series = await buildGradedSeriesWithRollups("u1", "WEIGHT", now);
|
||||
|
||||
expect(series.monthly.length).toBeGreaterThan(0);
|
||||
expect(series.yearly.length).toBeGreaterThan(0);
|
||||
expect(findMany).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -198,7 +198,7 @@ describe("generateMedicationComplianceStatusForUser — cache-read skips a stub"
|
||||
});
|
||||
|
||||
describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
|
||||
it("strips metric: tokens out of the cached summary + per-medication text", async () => {
|
||||
it("strips metric: tokens out of the cached summary", async () => {
|
||||
const now = new Date();
|
||||
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
|
||||
@@ -212,17 +212,13 @@ describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1
|
||||
createdAt: new Date(),
|
||||
} as never);
|
||||
|
||||
// The compliance prompt returns only a `{ summary }` envelope; the
|
||||
// per-medication cards are placeholder text built server-side, never
|
||||
// model-authored. So token-leak hardening only has to scrub `summary`.
|
||||
stubCompletion(
|
||||
JSON.stringify({
|
||||
summary:
|
||||
"Compliance held steady at 96%. metric:BLOOD_PRESSURE_SYS over the last 30 days.",
|
||||
medications: [
|
||||
{
|
||||
medicationId: "med-1",
|
||||
summary:
|
||||
"Ramipril taken on schedule. metric:PULSE no missed doses in 30 days.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -232,6 +228,9 @@ describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1
|
||||
|
||||
expect(result.summary).toBeTruthy();
|
||||
expect(result.summary).not.toContain("metric:");
|
||||
// A per-medication placeholder row is still surfaced for the active med.
|
||||
expect(result.medications).toHaveLength(1);
|
||||
expect(result.medications[0].medicationId).toBe("med-1");
|
||||
expect(result.medications[0].text).not.toContain("metric:");
|
||||
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
|
||||
expect(createCalls.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock("@/lib/db", () => ({
|
||||
user: { findUnique: vi.fn() },
|
||||
auditLog: { findFirst: vi.fn(), create: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
measurementRollup: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
},
|
||||
@@ -72,6 +73,7 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock("@/lib/db", () => ({
|
||||
user: { findUnique: vi.fn() },
|
||||
auditLog: { findFirst: vi.fn(), create: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
measurementRollup: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
@@ -36,6 +37,7 @@ import { generatePulseStatusForUser } from "../pulse-status";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
describe("generatePulseStatusForUser — provider timeout fallback", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ vi.mock("@/lib/db", () => ({
|
||||
user: { findUnique: vi.fn() },
|
||||
auditLog: { findFirst: vi.fn(), create: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
measurementRollup: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
@@ -44,6 +45,9 @@ function stubCompletion(
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// Cold rollup tier: the pulse graded series folds monthly/yearly from
|
||||
// the full-history `measurement.findMany` fallback on a tier miss.
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
describe("generatePulseStatusForUser — graded payload", () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
auditLog: { findFirst: vi.fn(), create: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
measurementRollup: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
@@ -43,6 +44,9 @@ function stubCompletion(
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// Cold rollup tier: the graded builder folds monthly/yearly from the
|
||||
// full-history `measurement.findMany` fallback the test already mocks.
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
describe("generateWeightStatusForUser — graded payload", () => {
|
||||
|
||||
@@ -29,17 +29,24 @@ import {
|
||||
} from "@/lib/insights/bucket-series";
|
||||
import {
|
||||
buildGradedSeriesFromPoints,
|
||||
buildGradedSeriesWithRollups,
|
||||
degradeStatusSnapshotToBudget,
|
||||
} from "@/lib/insights/graded-series";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import {
|
||||
type SupportedLocale,
|
||||
normalizeLocale,
|
||||
normalizeSummaryText,
|
||||
parseSummaryFromContent,
|
||||
persistStatusInsight,
|
||||
round,
|
||||
summarizeSeries,
|
||||
} from "@/lib/insights/status-shared";
|
||||
import { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
import { readFreshStatusText } from "@/lib/insights/status-cache";
|
||||
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
|
||||
type SupportedLocale = "de" | "en";
|
||||
|
||||
/**
|
||||
* Cap on the embedded correlation / paired-daily arrays. The Pearson
|
||||
* coefficients stay computed over the full overlap; only the row-by-row
|
||||
@@ -47,49 +54,6 @@ type SupportedLocale = "de" | "en";
|
||||
*/
|
||||
const CORRELATION_PAIR_CAP = 30;
|
||||
|
||||
function round(value: number, digits = 1): number {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
function normalizeSummaryText(value: string): string {
|
||||
return stripChartTokens(value).replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function normalizeLocale(value: string | null | undefined): SupportedLocale {
|
||||
return value === "en" ? "en" : "de";
|
||||
}
|
||||
|
||||
function summarizeSeries(series: Array<{ value: number }>) {
|
||||
if (series.length === 0) return null;
|
||||
const first = series[0].value;
|
||||
const last = series[series.length - 1].value;
|
||||
// v1.4.33 — fold sum/min/max into a single walk. The previous
|
||||
// `Math.min(...series.map(...))` / `Math.max(...series.map(...))`
|
||||
// spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics
|
||||
// path; see `.planning/round-v1433-analytics-500-report.md` §"Carry-
|
||||
// over". These helpers are fed bounded windows today so the crash
|
||||
// never reached them, but the spread allocates a transient args array
|
||||
// on every call — the fold is both stack-safe and cheaper.
|
||||
let sum = 0;
|
||||
let minVal = series[0].value;
|
||||
let maxVal = series[0].value;
|
||||
for (const entry of series) {
|
||||
sum += entry.value;
|
||||
if (entry.value < minVal) minVal = entry.value;
|
||||
if (entry.value > maxVal) maxVal = entry.value;
|
||||
}
|
||||
return {
|
||||
points: series.length,
|
||||
start: round(first, 2),
|
||||
end: round(last, 2),
|
||||
delta: round(last - first, 2),
|
||||
mean: round(sum / series.length, 2),
|
||||
min: round(minVal, 2),
|
||||
max: round(maxVal, 2),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair two daily-bucket series on `dayOffset`. The synthesised `date`
|
||||
* field is anchored at the UTC midnight of the Berlin calendar day —
|
||||
@@ -392,8 +356,14 @@ export async function generateBloodPressureStatusForUser(
|
||||
})),
|
||||
now,
|
||||
);
|
||||
const sysGraded = gradedFromDaily(sysSeries.daily);
|
||||
const diaGraded = gradedFromDaily(diaSeries.daily);
|
||||
// The two BP channels source their recent / weekly slices from a
|
||||
// bounded raw read and their monthly / yearly tail from the MONTH /
|
||||
// YEAR rollup tier (full-history in-memory fallback on a cold-tier
|
||||
// coverage miss). Mood has no rollup tier, so it stays an in-memory fold.
|
||||
const [sysGraded, diaGraded] = await Promise.all([
|
||||
buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_SYS", now),
|
||||
buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_DIA", now),
|
||||
]);
|
||||
const moodGraded = gradedFromDaily(moodSeries.daily);
|
||||
|
||||
const continuityVsSystolicPairs: PairedPoint[] = continuityVsSystolicSeries
|
||||
@@ -557,46 +527,29 @@ export async function generateBloodPressureStatusForUser(
|
||||
});
|
||||
}
|
||||
|
||||
let summary = "";
|
||||
try {
|
||||
const parsed = JSON.parse(outcome.content) as { summary?: string };
|
||||
if (typeof parsed.summary === "string") {
|
||||
summary = parsed.summary;
|
||||
} else {
|
||||
summary = outcome.content;
|
||||
}
|
||||
} catch {
|
||||
summary = outcome.content;
|
||||
}
|
||||
|
||||
summary = normalizeSummaryText(summary);
|
||||
const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content));
|
||||
if (!summary) {
|
||||
throw new Error(
|
||||
"Blood-pressure-status summary was empty after normalization",
|
||||
);
|
||||
}
|
||||
|
||||
const created = await prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
action: cacheAction,
|
||||
details: JSON.stringify({
|
||||
dateKey: todayKey,
|
||||
locale,
|
||||
text: summary,
|
||||
providerType: outcome.providerType,
|
||||
model: outcome.model,
|
||||
tokensUsed: outcome.tokensUsed,
|
||||
}),
|
||||
},
|
||||
select: { createdAt: true },
|
||||
const updatedAt = await persistStatusInsight({
|
||||
userId,
|
||||
cacheAction,
|
||||
todayKey,
|
||||
locale,
|
||||
text: summary,
|
||||
providerType: outcome.providerType,
|
||||
model: outcome.model,
|
||||
tokensUsed: outcome.tokensUsed,
|
||||
});
|
||||
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: summary,
|
||||
cached: false,
|
||||
updatedAt: created.createdAt.toISOString(),
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,62 +8,25 @@ import {
|
||||
} from "@/lib/insights/memory";
|
||||
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
|
||||
import {
|
||||
buildGradedSeriesFromPoints,
|
||||
buildGradedSeriesWithRollups,
|
||||
scaleGradedSeries,
|
||||
degradeStatusSnapshotToBudget,
|
||||
} from "@/lib/insights/graded-series";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import {
|
||||
type SupportedLocale,
|
||||
normalizeLocale,
|
||||
normalizeSummaryText,
|
||||
parseSummaryFromContent,
|
||||
persistStatusInsight,
|
||||
round,
|
||||
summarizeSeries,
|
||||
} from "@/lib/insights/status-shared";
|
||||
import { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
import { readFreshStatusText } from "@/lib/insights/status-cache";
|
||||
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
|
||||
type SupportedLocale = "de" | "en";
|
||||
|
||||
function round(value: number, digits = 1): number {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
function normalizeSummaryText(value: string): string {
|
||||
return stripChartTokens(value).replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function normalizeLocale(value: string | null | undefined): SupportedLocale {
|
||||
return value === "en" ? "en" : "de";
|
||||
}
|
||||
|
||||
function summarizeSeries(series: Array<{ value: number }>) {
|
||||
if (series.length === 0) return null;
|
||||
const first = series[0].value;
|
||||
const last = series[series.length - 1].value;
|
||||
// v1.4.33 — fold sum/min/max into a single walk. The previous
|
||||
// `Math.min(...series.map(...))` / `Math.max(...series.map(...))`
|
||||
// spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics
|
||||
// path and is the same anti-pattern fixed in `summarize()` (see
|
||||
// `.planning/round-v1433-analytics-500-report.md` §"Carry-over").
|
||||
// These helpers feed off bounded windows today so they did not crash,
|
||||
// but the spread allocates a transient args array on every call;
|
||||
// folding once is both stack-safe and cheaper.
|
||||
let sum = 0;
|
||||
let minVal = series[0].value;
|
||||
let maxVal = series[0].value;
|
||||
for (const entry of series) {
|
||||
sum += entry.value;
|
||||
if (entry.value < minVal) minVal = entry.value;
|
||||
if (entry.value > maxVal) maxVal = entry.value;
|
||||
}
|
||||
return {
|
||||
points: series.length,
|
||||
start: round(first, 2),
|
||||
end: round(last, 2),
|
||||
delta: round(last - first, 2),
|
||||
mean: round(sum / series.length, 2),
|
||||
min: round(minVal, 2),
|
||||
max: round(maxVal, 2),
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateBmiStatusForUser(
|
||||
userId: string,
|
||||
options?: {
|
||||
@@ -143,7 +106,16 @@ export async function generateBmiStatusForUser(
|
||||
// `applyPayloadBudget` daily buckets drive the latest/previous focus;
|
||||
// the compact graded series is what reaches the prompt.
|
||||
const weightSeries = applyPayloadBudget(bmiPoints, { now });
|
||||
const bmiGraded = buildGradedSeriesFromPoints(bmiPoints, now);
|
||||
// BMI has no rollup tier of its own — it is weight ÷ height², a linear
|
||||
// transform by the per-user height factor. Read the WEIGHT tier (recent
|
||||
// / weekly from a bounded raw read, monthly / yearly from MONTH / YEAR)
|
||||
// and scale by 1 / heightFactor; the scaled series is exact.
|
||||
const weightGraded = await buildGradedSeriesWithRollups(
|
||||
userId,
|
||||
"WEIGHT",
|
||||
now,
|
||||
);
|
||||
const bmiGraded = scaleGradedSeries(weightGraded, 1 / heightFactor, 2);
|
||||
const bmiSeries = {
|
||||
daily: weightSeries.daily,
|
||||
monthly: weightSeries.monthly,
|
||||
@@ -259,44 +231,27 @@ export async function generateBmiStatusForUser(
|
||||
});
|
||||
}
|
||||
|
||||
let summary = "";
|
||||
try {
|
||||
const parsed = JSON.parse(outcome.content) as { summary?: string };
|
||||
if (typeof parsed.summary === "string") {
|
||||
summary = parsed.summary;
|
||||
} else {
|
||||
summary = outcome.content;
|
||||
}
|
||||
} catch {
|
||||
summary = outcome.content;
|
||||
}
|
||||
|
||||
summary = normalizeSummaryText(summary);
|
||||
const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content));
|
||||
if (!summary) {
|
||||
throw new Error("Bmi-status summary was empty after normalization");
|
||||
}
|
||||
|
||||
const created = await prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
action: cacheAction,
|
||||
details: JSON.stringify({
|
||||
dateKey: todayKey,
|
||||
locale,
|
||||
text: summary,
|
||||
providerType: outcome.providerType,
|
||||
model: outcome.model,
|
||||
tokensUsed: outcome.tokensUsed,
|
||||
}),
|
||||
},
|
||||
select: { createdAt: true },
|
||||
const updatedAt = await persistStatusInsight({
|
||||
userId,
|
||||
cacheAction,
|
||||
todayKey,
|
||||
locale,
|
||||
text: summary,
|
||||
providerType: outcome.providerType,
|
||||
model: outcome.model,
|
||||
tokensUsed: outcome.tokensUsed,
|
||||
});
|
||||
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: summary,
|
||||
cached: false,
|
||||
updatedAt: created.createdAt.toISOString(),
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -16,61 +16,31 @@ import {
|
||||
buildGradedSeriesFromPoints,
|
||||
degradeStatusSnapshotToBudget,
|
||||
} from "@/lib/insights/graded-series";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import {
|
||||
normalizeLocale,
|
||||
normalizeSummaryText,
|
||||
parseSummaryFromContent,
|
||||
persistStatusInsight,
|
||||
round,
|
||||
summarizeSeries,
|
||||
} from "@/lib/insights/status-shared";
|
||||
import { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
import { readFreshStatusText } from "@/lib/insights/status-cache";
|
||||
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
|
||||
type SupportedLocale = "de" | "en";
|
||||
|
||||
// Derived from canonical enum so a new measurement type is auto-included
|
||||
// in the AI general-status fetch (V3 audit: enum drift cousins).
|
||||
const MEASUREMENT_TYPES = measurementTypeEnum.options;
|
||||
|
||||
function round(value: number, digits = 1): number {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
function normalizeSummaryText(value: string): string {
|
||||
return stripChartTokens(value).replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function summarizeSeries(series: Array<{ value: number }>) {
|
||||
if (series.length === 0) return null;
|
||||
const first = series[0].value;
|
||||
const last = series[series.length - 1].value;
|
||||
// v1.4.33 — fold sum/min/max into a single walk. The previous
|
||||
// `Math.min(...series.map(...))` / `Math.max(...series.map(...))`
|
||||
// spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics
|
||||
// path; see `.planning/round-v1433-analytics-500-report.md` §"Carry-
|
||||
// over". These helpers are fed bounded windows today so the crash
|
||||
// never reached them, but the spread allocates a transient args array
|
||||
// on every call — the fold is both stack-safe and cheaper.
|
||||
let sum = 0;
|
||||
let minVal = series[0].value;
|
||||
let maxVal = series[0].value;
|
||||
for (const entry of series) {
|
||||
sum += entry.value;
|
||||
if (entry.value < minVal) minVal = entry.value;
|
||||
if (entry.value > maxVal) maxVal = entry.value;
|
||||
}
|
||||
return {
|
||||
points: series.length,
|
||||
start: round(first, 2),
|
||||
end: round(last, 2),
|
||||
delta: round(last - first, 2),
|
||||
mean: round(sum / series.length, 2),
|
||||
min: round(minVal, 2),
|
||||
max: round(maxVal, 2),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLocale(value: string | null | undefined): SupportedLocale {
|
||||
return value === "en" ? "en" : "de";
|
||||
}
|
||||
// The general overview folds its per-type graded series in memory from a
|
||||
// single bounded read (take 5000, desc) rather than per-type rollup-tier
|
||||
// reads: a many-metric account would otherwise fan out ~15 metrics × 3
|
||||
// rollup round-trips per render. `degradeStatusSnapshotToBudget` already
|
||||
// caps the multi-metric snapshot, so the bounded in-memory fold is the
|
||||
// deliberate design here; the rollup tier is wired into the focused
|
||||
// single-metric cards (weight / pulse / bmi / blood-pressure) instead.
|
||||
|
||||
export async function generateGeneralStatusForUser(
|
||||
userId: string,
|
||||
@@ -382,44 +352,27 @@ export async function generateGeneralStatusForUser(
|
||||
});
|
||||
}
|
||||
|
||||
let summary = "";
|
||||
try {
|
||||
const parsed = JSON.parse(outcome.content) as { summary?: string };
|
||||
if (typeof parsed.summary === "string") {
|
||||
summary = parsed.summary;
|
||||
} else {
|
||||
summary = outcome.content;
|
||||
}
|
||||
} catch {
|
||||
summary = outcome.content;
|
||||
}
|
||||
|
||||
summary = normalizeSummaryText(summary);
|
||||
const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content));
|
||||
if (!summary) {
|
||||
throw new Error("General-status summary was empty after normalization");
|
||||
}
|
||||
|
||||
const created = await prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
action: cacheAction,
|
||||
details: JSON.stringify({
|
||||
dateKey: todayKey,
|
||||
locale,
|
||||
text: summary,
|
||||
providerType: outcome.providerType,
|
||||
model: outcome.model,
|
||||
tokensUsed: outcome.tokensUsed,
|
||||
}),
|
||||
},
|
||||
select: { createdAt: true },
|
||||
const updatedAt = await persistStatusInsight({
|
||||
userId,
|
||||
cacheAction,
|
||||
todayKey,
|
||||
locale,
|
||||
text: summary,
|
||||
providerType: outcome.providerType,
|
||||
model: outcome.model,
|
||||
tokensUsed: outcome.tokensUsed,
|
||||
});
|
||||
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: summary,
|
||||
cached: false,
|
||||
updatedAt: created.createdAt.toISOString(),
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,13 +4,20 @@
|
||||
* Background — see `.planning/research/ai-payload-compression-audit.md`.
|
||||
* The seven per-card status generators embedded the FULL daily array
|
||||
* per metric (`bucketSeries`, up to ~360 daily buckets + ~24 monthly)
|
||||
* plus correlation pair arrays, and never touched the WEEK / MONTH /
|
||||
* YEAR rollup tier. A daily-weigher's `weight-status` snapshot ran
|
||||
* plus correlation pair arrays, and originally never touched the WEEK /
|
||||
* MONTH / YEAR rollup tier. A daily-weigher's `weight-status` snapshot ran
|
||||
* ~23 KB for weight alone; `general-status` across many Apple-Health
|
||||
* types could blow past 100 K tokens. The Coach is the one path that
|
||||
* already grades its timeline (recent days verbatim → weekly means →
|
||||
* bounded window).
|
||||
*
|
||||
* As of v1.8.0 the focused single-metric cards (weight / pulse / bmi /
|
||||
* blood-pressure) source their monthly / yearly slices from the MONTH /
|
||||
* YEAR rollup tier via `buildGradedSeriesWithRollups`, so the coarse
|
||||
* tiers finally earn their write amplification. The multi-metric
|
||||
* `general-status` and the tier-less mood / adherence series stay on the
|
||||
* bounded in-memory fold (`buildGradedSeriesFromPoints`).
|
||||
*
|
||||
* This module is the single graded shape every status path uses:
|
||||
*
|
||||
* recent last ~21 days daily min/max/mean/n (~14-21 rows)
|
||||
@@ -43,7 +50,6 @@
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
aggregateWmyBuckets,
|
||||
readBestGranularityRollups,
|
||||
type RollupBucketRow,
|
||||
} from "@/lib/rollups/measurement-read-wmy";
|
||||
@@ -310,12 +316,22 @@ function rollupYearly(rows: RollupBucketRow[]): YearlyBucket[] {
|
||||
* bounded raw read and the monthly / yearly slices read from the
|
||||
* pre-aggregated rollup tier.
|
||||
*
|
||||
* The raw read is capped to the recent + weekly horizon (last ~90 days),
|
||||
* so dense accounts never pull the full multi-year history into memory.
|
||||
* The rollup router picks MONTH for the ~1-year window and YEAR for the
|
||||
* multi-year tail, falling back to a finer tier on coverage miss; when
|
||||
* the tier has no coverage at all the in-memory folder still produces
|
||||
* monthly/yearly buckets from whatever the raw read returned.
|
||||
* The recent / weekly read is capped to the recent + weekly horizon
|
||||
* (last ~90 days), so the common warm-tier path never pulls the full
|
||||
* multi-year history into memory. The rollup router picks MONTH for the
|
||||
* ~1-year window and YEAR for the multi-year tail.
|
||||
*
|
||||
* Coverage-miss fallback: when the tier has no MONTH (resp. YEAR)
|
||||
* coverage for a metric — a fresh account the boot-backfill has not yet
|
||||
* caught up on — the bounded ~90-day read can NOT supply the monthly /
|
||||
* yearly slices (every row in it folds into recent / weekly), so naively
|
||||
* reusing it would ship empty monthly / yearly arrays even when years of
|
||||
* raw history exist. On that miss this falls back to a full-history
|
||||
* in-memory fold so the coarse slices are populated from the raw rows
|
||||
* rather than left falsely empty. The full read only happens when the
|
||||
* tier is cold, which is exactly the case the write-amplified tier was
|
||||
* meant to avoid — so the hot path stays bounded and the cold path stays
|
||||
* correct.
|
||||
*/
|
||||
export async function buildGradedSeriesWithRollups(
|
||||
userId: string,
|
||||
@@ -324,7 +340,7 @@ export async function buildGradedSeriesWithRollups(
|
||||
): Promise<GradedSeries> {
|
||||
const since = new Date(now.getTime() - WEEKLY_WINDOW_MS);
|
||||
const rawRecent = await prisma.measurement.findMany({
|
||||
where: { userId, type, measuredAt: { gte: since } },
|
||||
where: { userId, type, deletedAt: null, measuredAt: { gte: since } },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
});
|
||||
@@ -339,22 +355,49 @@ export async function buildGradedSeriesWithRollups(
|
||||
// Yearly slice: route a multi-year window through the tier.
|
||||
const yearlyRouted = await readBestGranularityRollups(userId, type, 1095);
|
||||
|
||||
let monthly: MonthlyBucket[] = recentGraded.monthly;
|
||||
let yearly: YearlyBucket[] = recentGraded.yearly;
|
||||
const monthlyCovered =
|
||||
monthlyRouted !== null && monthlyRouted.granularity === "MONTH";
|
||||
const yearlyCovered =
|
||||
yearlyRouted !== null && yearlyRouted.granularity === "YEAR";
|
||||
|
||||
if (monthlyRouted && monthlyRouted.granularity === "MONTH") {
|
||||
let monthly: MonthlyBucket[];
|
||||
let yearly: YearlyBucket[];
|
||||
|
||||
if (monthlyCovered) {
|
||||
// Drop the months already covered by recent/weekly (last ~90 d).
|
||||
const cutoff = new Date(now.getTime() - WEEKLY_WINDOW_MS);
|
||||
monthly = rollupMonthly(
|
||||
monthlyRouted.rows.filter((r) => r.bucketStart < cutoff),
|
||||
).slice(-MONTHLY_MONTHS);
|
||||
} else {
|
||||
monthly = [];
|
||||
}
|
||||
if (yearlyRouted && yearlyRouted.granularity === "YEAR") {
|
||||
if (yearlyCovered) {
|
||||
// Keep only years older than the monthly horizon.
|
||||
const cutoff = new Date(now.getTime() - MONTHLY_WINDOW_MS);
|
||||
yearly = rollupYearly(
|
||||
yearlyRouted.rows.filter((r) => r.bucketStart < cutoff),
|
||||
);
|
||||
} else {
|
||||
yearly = [];
|
||||
}
|
||||
|
||||
// Tier coverage miss for one or both coarse slices: the bounded
|
||||
// ~90-day read can't supply them, so fold the FULL history in memory
|
||||
// and take whichever slices the tier left empty. The full read is the
|
||||
// exception (cold-tier accounts), never the warm-tier norm.
|
||||
if (!monthlyCovered || !yearlyCovered) {
|
||||
const allRows = await prisma.measurement.findMany({
|
||||
where: { userId, type, deletedAt: null },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
});
|
||||
const fullGraded = buildGradedSeriesFromPoints(
|
||||
allRows.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
|
||||
now,
|
||||
);
|
||||
if (!monthlyCovered) monthly = fullGraded.monthly;
|
||||
if (!yearlyCovered) yearly = fullGraded.yearly;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -365,6 +408,50 @@ export async function buildGradedSeriesWithRollups(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a constant multiplier to every value in a graded series, keeping
|
||||
* the bucket keys / counts intact. BMI = weight ÷ height² is a linear
|
||||
* transform of weight by a per-user constant, so the BMI status card can
|
||||
* read the WEIGHT rollup tier and scale it rather than maintaining a
|
||||
* separate (non-existent) BMI tier — `mean / min / max / slope` all carry
|
||||
* the factor exactly, so the scaled series is identical to folding the
|
||||
* derived BMI points directly.
|
||||
*/
|
||||
export function scaleGradedSeries(
|
||||
series: GradedSeries,
|
||||
factor: number,
|
||||
digits = 2,
|
||||
): GradedSeries {
|
||||
const r = (v: number) => round(v * factor, digits);
|
||||
return {
|
||||
recent: series.recent.map((b) => ({
|
||||
...b,
|
||||
min: r(b.min),
|
||||
max: r(b.max),
|
||||
mean: r(b.mean),
|
||||
})),
|
||||
weekly: series.weekly.map((b) => ({
|
||||
...b,
|
||||
min: r(b.min),
|
||||
max: r(b.max),
|
||||
mean: r(b.mean),
|
||||
})),
|
||||
monthly: series.monthly.map((b) => ({
|
||||
...b,
|
||||
min: r(b.min),
|
||||
max: r(b.max),
|
||||
mean: r(b.mean),
|
||||
})),
|
||||
yearly: series.yearly.map((b) => ({
|
||||
...b,
|
||||
min: r(b.min),
|
||||
max: r(b.max),
|
||||
mean: r(b.mean),
|
||||
slope: b.slope === null ? null : round(b.slope * factor, 4),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assembled-snapshot soft char cap, mirroring the Coach's
|
||||
* `MAX_SNAPSHOT_CHARS` (~24 000 chars ≈ ~6 000 tokens against the
|
||||
@@ -455,40 +542,3 @@ export function degradeStatusSnapshotToBudget(
|
||||
shed.push("recent");
|
||||
return shed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Headline summary across the whole graded series — min/max/mean/n —
|
||||
* for the snapshot's summary line. Composes the per-bucket stats; uses
|
||||
* `aggregateWmyBuckets` semantics for the count-weighted mean.
|
||||
*/
|
||||
export function summarizeGradedSeries(series: GradedSeries): {
|
||||
n: number;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
mean: number | null;
|
||||
} {
|
||||
const rows: RollupBucketRow[] = [];
|
||||
const push = (mean: number, min: number, max: number, n: number) =>
|
||||
rows.push({
|
||||
bucketStart: new Date(0),
|
||||
count: n,
|
||||
mean,
|
||||
sd: null,
|
||||
slope: null,
|
||||
r2: null,
|
||||
sumValue: null,
|
||||
minValue: min,
|
||||
maxValue: max,
|
||||
});
|
||||
for (const r of series.recent) push(r.mean, r.min, r.max, r.n);
|
||||
for (const w of series.weekly) push(w.mean, w.min, w.max, w.n);
|
||||
for (const m of series.monthly) push(m.mean, m.min, m.max, m.n);
|
||||
for (const y of series.yearly) push(y.mean, y.min, y.max, y.n);
|
||||
const agg = aggregateWmyBuckets(rows);
|
||||
return {
|
||||
n: agg.count,
|
||||
min: agg.min === null ? null : round(agg.min),
|
||||
max: agg.max === null ? null : round(agg.max),
|
||||
mean: agg.mean === null ? null : round(agg.mean),
|
||||
};
|
||||
}
|
||||
@@ -17,11 +17,15 @@ import {
|
||||
getPreviousInsightContext,
|
||||
} from "@/lib/insights/memory";
|
||||
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
|
||||
import { buildGradedSeriesFromPoints } from "@/lib/insights/graded-series";
|
||||
import { degradeStatusSnapshotToBudget } from "@/lib/insights/graded-series";
|
||||
import {
|
||||
buildGradedSeriesFromPoints,
|
||||
degradeStatusSnapshotToBudget,
|
||||
} from "@/lib/insights/graded-series";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
type SupportedLocale,
|
||||
normalizeLocale,
|
||||
normalizeSummaryText,
|
||||
parseSummaryFromContent,
|
||||
round,
|
||||
} from "@/lib/insights/status-shared";
|
||||
import { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
import { isTimeoutStub } from "@/lib/insights/status-cache";
|
||||
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
|
||||
@@ -32,26 +36,11 @@ import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
const COMPLIANCE_HISTORY_DAYS = 360 + 24 * 30;
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
type SupportedLocale = "de" | "en";
|
||||
|
||||
interface MedicationSummaryItem {
|
||||
medicationId: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function round(value: number, digits = 1): number {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
function normalizeSummaryText(value: string): string {
|
||||
return stripChartTokens(value).replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function normalizeLocale(value: string | null | undefined): SupportedLocale {
|
||||
return value === "en" ? "en" : "de";
|
||||
}
|
||||
|
||||
export async function generateMedicationComplianceStatusForUser(
|
||||
userId: string,
|
||||
options?: {
|
||||
@@ -360,49 +349,22 @@ export async function generateMedicationComplianceStatusForUser(
|
||||
};
|
||||
}
|
||||
|
||||
const content = outcome.content;
|
||||
|
||||
let summary = "";
|
||||
let medicationSummaries: MedicationSummaryItem[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(content) as {
|
||||
summary?: string;
|
||||
medications?: Array<{ medicationId?: string; summary?: string }>;
|
||||
};
|
||||
summary = typeof parsed.summary === "string" ? parsed.summary : content;
|
||||
|
||||
const incomingMap = new Map<string, string>();
|
||||
for (const entry of parsed.medications ?? []) {
|
||||
if (
|
||||
typeof entry?.medicationId === "string" &&
|
||||
typeof entry?.summary === "string" &&
|
||||
entry.summary.trim().length > 0
|
||||
) {
|
||||
incomingMap.set(entry.medicationId, entry.summary);
|
||||
}
|
||||
}
|
||||
|
||||
medicationSummaries = medicationSnapshots.map((medication) => ({
|
||||
// The compliance prompt returns a single `{ summary }` envelope — it
|
||||
// does not emit per-medication text. The per-medication cards carry a
|
||||
// placeholder so the UI surfaces a row per active medication; the
|
||||
// overall `summary` is the model-authored assessment.
|
||||
const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content));
|
||||
const medicationSummaries: MedicationSummaryItem[] = medicationSnapshots.map(
|
||||
(medication) => ({
|
||||
medicationId: medication.medicationId,
|
||||
text: normalizeSummaryText(
|
||||
incomingMap.get(medication.medicationId) ??
|
||||
(locale === "de"
|
||||
? `${medication.name}: Es liegen noch nicht genügend konsistente Detaildaten für eine belastbare Kurzbewertung vor.`
|
||||
: `${medication.name}: There is currently not enough consistent detail data for a robust short assessment.`),
|
||||
),
|
||||
}));
|
||||
} catch {
|
||||
summary = content;
|
||||
medicationSummaries = medicationSnapshots.map((medication) => ({
|
||||
medicationId: medication.medicationId,
|
||||
text:
|
||||
locale === "de"
|
||||
? `${medication.name}: Die medikamentenspezifische Kurzbewertung konnte heute nicht separat aufbereitet werden.`
|
||||
: `${medication.name}: The medication-specific short assessment could not be prepared separately today.`,
|
||||
}));
|
||||
}
|
||||
? `${medication.name}: Es liegen noch nicht genügend konsistente Detaildaten für eine belastbare Kurzbewertung vor.`
|
||||
: `${medication.name}: There is currently not enough consistent detail data for a robust short assessment.`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
summary = normalizeSummaryText(summary);
|
||||
if (!summary) {
|
||||
throw new Error(
|
||||
"Medication-compliance-status summary was empty after normalization",
|
||||
|
||||
@@ -18,58 +18,21 @@ import {
|
||||
buildGradedSeriesFromPoints,
|
||||
degradeStatusSnapshotToBudget,
|
||||
} from "@/lib/insights/graded-series";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import {
|
||||
type SupportedLocale,
|
||||
normalizeLocale,
|
||||
normalizeSummaryText,
|
||||
parseSummaryFromContent,
|
||||
persistStatusInsight,
|
||||
round,
|
||||
summarizeSeries,
|
||||
} from "@/lib/insights/status-shared";
|
||||
import { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
import { readFreshStatusText } from "@/lib/insights/status-cache";
|
||||
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
|
||||
type SupportedLocale = "de" | "en";
|
||||
|
||||
function round(value: number, digits = 1): number {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
function normalizeSummaryText(value: string): string {
|
||||
return stripChartTokens(value).replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function normalizeLocale(value: string | null | undefined): SupportedLocale {
|
||||
return value === "en" ? "en" : "de";
|
||||
}
|
||||
|
||||
function summarizeSeries(series: Array<{ value: number }>) {
|
||||
if (series.length === 0) return null;
|
||||
const first = series[0].value;
|
||||
const last = series[series.length - 1].value;
|
||||
// v1.4.33 — fold sum/min/max into a single walk. The previous
|
||||
// `Math.min(...series.map(...))` / `Math.max(...series.map(...))`
|
||||
// spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics
|
||||
// path; see `.planning/round-v1433-analytics-500-report.md` §"Carry-
|
||||
// over". These helpers are fed bounded windows today so the crash
|
||||
// never reached them, but the spread allocates a transient args array
|
||||
// on every call — the fold is both stack-safe and cheaper.
|
||||
let sum = 0;
|
||||
let minVal = series[0].value;
|
||||
let maxVal = series[0].value;
|
||||
for (const entry of series) {
|
||||
sum += entry.value;
|
||||
if (entry.value < minVal) minVal = entry.value;
|
||||
if (entry.value > maxVal) maxVal = entry.value;
|
||||
}
|
||||
return {
|
||||
points: series.length,
|
||||
start: round(first, 2),
|
||||
end: round(last, 2),
|
||||
delta: round(last - first, 2),
|
||||
mean: round(sum / series.length, 2),
|
||||
min: round(minVal, 2),
|
||||
max: round(maxVal, 2),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair two daily-bucket series on `dayOffset`. The synthesised `date`
|
||||
* field is anchored at the UTC midnight of the Berlin calendar day —
|
||||
@@ -423,44 +386,27 @@ export async function generateMoodStatusForUser(
|
||||
});
|
||||
}
|
||||
|
||||
let summary = "";
|
||||
try {
|
||||
const parsed = JSON.parse(outcome.content) as { summary?: string };
|
||||
if (typeof parsed.summary === "string") {
|
||||
summary = parsed.summary;
|
||||
} else {
|
||||
summary = outcome.content;
|
||||
}
|
||||
} catch {
|
||||
summary = outcome.content;
|
||||
}
|
||||
|
||||
summary = normalizeSummaryText(summary);
|
||||
const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content));
|
||||
if (!summary) {
|
||||
throw new Error("Mood-status summary was empty after normalization");
|
||||
}
|
||||
|
||||
const created = await prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
action: cacheAction,
|
||||
details: JSON.stringify({
|
||||
dateKey: todayKey,
|
||||
locale,
|
||||
text: summary,
|
||||
providerType: outcome.providerType,
|
||||
model: outcome.model,
|
||||
tokensUsed: outcome.tokensUsed,
|
||||
}),
|
||||
},
|
||||
select: { createdAt: true },
|
||||
const updatedAt = await persistStatusInsight({
|
||||
userId,
|
||||
cacheAction,
|
||||
todayKey,
|
||||
locale,
|
||||
text: summary,
|
||||
providerType: outcome.providerType,
|
||||
model: outcome.model,
|
||||
tokensUsed: outcome.tokensUsed,
|
||||
});
|
||||
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: summary,
|
||||
cached: false,
|
||||
updatedAt: created.createdAt.toISOString(),
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -15,60 +15,24 @@ import { getNoKeyPulseStatusText } from "@/lib/insights/no-key-fallbacks";
|
||||
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
|
||||
import {
|
||||
buildGradedSeriesFromPoints,
|
||||
buildGradedSeriesWithRollups,
|
||||
degradeStatusSnapshotToBudget,
|
||||
} from "@/lib/insights/graded-series";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import {
|
||||
type SupportedLocale,
|
||||
normalizeLocale,
|
||||
normalizeSummaryText,
|
||||
parseSummaryFromContent,
|
||||
persistStatusInsight,
|
||||
round,
|
||||
summarizeSeries,
|
||||
} from "@/lib/insights/status-shared";
|
||||
import { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
import { readFreshStatusText } from "@/lib/insights/status-cache";
|
||||
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
|
||||
type SupportedLocale = "de" | "en";
|
||||
|
||||
function round(value: number, digits = 1): number {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
function normalizeSummaryText(value: string): string {
|
||||
return stripChartTokens(value).replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function normalizeLocale(value: string | null | undefined): SupportedLocale {
|
||||
return value === "en" ? "en" : "de";
|
||||
}
|
||||
|
||||
function summarizeSeries(series: Array<{ value: number }>) {
|
||||
if (series.length === 0) return null;
|
||||
const first = series[0].value;
|
||||
const last = series[series.length - 1].value;
|
||||
// v1.4.33 — fold sum/min/max into a single walk. The previous
|
||||
// `Math.min(...series.map(...))` / `Math.max(...series.map(...))`
|
||||
// spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics
|
||||
// path; see `.planning/round-v1433-analytics-500-report.md` §"Carry-
|
||||
// over". These helpers are fed bounded windows today so the crash
|
||||
// never reached them, but the spread allocates a transient args array
|
||||
// on every call — the fold is both stack-safe and cheaper.
|
||||
let sum = 0;
|
||||
let minVal = series[0].value;
|
||||
let maxVal = series[0].value;
|
||||
for (const entry of series) {
|
||||
sum += entry.value;
|
||||
if (entry.value < minVal) minVal = entry.value;
|
||||
if (entry.value > maxVal) maxVal = entry.value;
|
||||
}
|
||||
return {
|
||||
points: series.length,
|
||||
start: round(first, 2),
|
||||
end: round(last, 2),
|
||||
delta: round(last - first, 2),
|
||||
mean: round(sum / series.length, 2),
|
||||
min: round(minVal, 2),
|
||||
max: round(maxVal, 2),
|
||||
};
|
||||
}
|
||||
|
||||
export async function generatePulseStatusForUser(
|
||||
userId: string,
|
||||
options?: {
|
||||
@@ -137,7 +101,10 @@ export async function generatePulseStatusForUser(
|
||||
// below (latest, in-target %, delta). They are NOT embedded in the
|
||||
// prompt — the compact graded series replaces the full daily array.
|
||||
const pulseSeries = applyPayloadBudget(pulsePoints, { now });
|
||||
const pulseGraded = buildGradedSeriesFromPoints(pulsePoints, now);
|
||||
// Primary metric: recent / weekly fold from the bounded raw read, the
|
||||
// monthly / yearly tail comes from the MONTH / YEAR rollup tier (with
|
||||
// a full-history in-memory fallback on a cold-tier coverage miss).
|
||||
const pulseGraded = await buildGradedSeriesWithRollups(userId, "PULSE", now);
|
||||
const pulseSummary = summarizeSeries(
|
||||
pulseSeries.daily.map((bucket) => ({ value: bucket.value })),
|
||||
);
|
||||
@@ -307,44 +274,27 @@ export async function generatePulseStatusForUser(
|
||||
});
|
||||
}
|
||||
|
||||
let summary = "";
|
||||
try {
|
||||
const parsed = JSON.parse(outcome.content) as { summary?: string };
|
||||
if (typeof parsed.summary === "string") {
|
||||
summary = parsed.summary;
|
||||
} else {
|
||||
summary = outcome.content;
|
||||
}
|
||||
} catch {
|
||||
summary = outcome.content;
|
||||
}
|
||||
|
||||
summary = normalizeSummaryText(summary);
|
||||
const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content));
|
||||
if (!summary) {
|
||||
throw new Error("Pulse-status summary was empty after normalization");
|
||||
}
|
||||
|
||||
const created = await prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
action: cacheAction,
|
||||
details: JSON.stringify({
|
||||
dateKey: todayKey,
|
||||
locale,
|
||||
text: summary,
|
||||
providerType: outcome.providerType,
|
||||
model: outcome.model,
|
||||
tokensUsed: outcome.tokensUsed,
|
||||
}),
|
||||
},
|
||||
select: { createdAt: true },
|
||||
const updatedAt = await persistStatusInsight({
|
||||
userId,
|
||||
cacheAction,
|
||||
todayKey,
|
||||
locale,
|
||||
text: summary,
|
||||
providerType: outcome.providerType,
|
||||
model: outcome.model,
|
||||
tokensUsed: outcome.tokensUsed,
|
||||
});
|
||||
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: summary,
|
||||
cached: false,
|
||||
updatedAt: created.createdAt.toISOString(),
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Shared helpers for the seven per-card status generators.
|
||||
*
|
||||
* Every `src/lib/insights/*-status.ts` generator carried byte-identical
|
||||
* copies of `round`, `normalizeSummaryText`, `normalizeLocale`, and (in
|
||||
* six of seven) `summarizeSeries`, plus an identical "parse the model's
|
||||
* `{summary}` envelope" block and an identical `prisma.auditLog.create`
|
||||
* persist block. This module is the single source of truth so a change
|
||||
* to the rounding precision, the chart-token scrub, or the cache-row
|
||||
* shape lands once rather than seven times.
|
||||
*
|
||||
* The shapes are intentionally narrow — `SupportedLocale` is `de | en`
|
||||
* because the status prompts ship only those two locales. The
|
||||
* medication-compliance generator writes a richer cache `details` shape
|
||||
* (it carries a per-medication array), so it shares `round`,
|
||||
* `normalizeSummaryText`, `normalizeLocale`, and `parseSummaryFromContent`
|
||||
* but keeps its own `auditLog.create`.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
|
||||
export type SupportedLocale = "de" | "en";
|
||||
|
||||
/** Round to `digits` decimal places. */
|
||||
export function round(value: number, digits = 1): number {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
/** Scrub chart tokens and collapse whitespace out of a model summary. */
|
||||
export function normalizeSummaryText(value: string): string {
|
||||
return stripChartTokens(value).replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/** Narrow an arbitrary locale string to the two the prompts support. */
|
||||
export function normalizeLocale(
|
||||
value: string | null | undefined,
|
||||
): SupportedLocale {
|
||||
return value === "en" ? "en" : "de";
|
||||
}
|
||||
|
||||
export interface SeriesSummary {
|
||||
points: number;
|
||||
start: number;
|
||||
end: number;
|
||||
delta: number;
|
||||
mean: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a value series into a single start/end/delta/mean/min/max
|
||||
* summary. Returns null for an empty series.
|
||||
*
|
||||
* v1.4.33 — fold sum/min/max into a single walk. The previous
|
||||
* `Math.min(...series.map(...))` / `Math.max(...series.map(...))`
|
||||
* spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics
|
||||
* path; see `.planning/round-v1433-analytics-500-report.md` §"Carry-
|
||||
* over". These helpers are fed bounded windows today so the crash
|
||||
* never reached them, but the spread allocates a transient args array
|
||||
* on every call — the fold is both stack-safe and cheaper.
|
||||
*/
|
||||
export function summarizeSeries(
|
||||
series: Array<{ value: number }>,
|
||||
): SeriesSummary | null {
|
||||
if (series.length === 0) return null;
|
||||
const first = series[0].value;
|
||||
const last = series[series.length - 1].value;
|
||||
let sum = 0;
|
||||
let minVal = series[0].value;
|
||||
let maxVal = series[0].value;
|
||||
for (const entry of series) {
|
||||
sum += entry.value;
|
||||
if (entry.value < minVal) minVal = entry.value;
|
||||
if (entry.value > maxVal) maxVal = entry.value;
|
||||
}
|
||||
return {
|
||||
points: series.length,
|
||||
start: round(first, 2),
|
||||
end: round(last, 2),
|
||||
delta: round(last - first, 2),
|
||||
mean: round(sum / series.length, 2),
|
||||
min: round(minVal, 2),
|
||||
max: round(maxVal, 2),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the `summary` string out of a model completion. The status
|
||||
* prompts return a `{ "summary": "…" }` envelope; a model that ignores
|
||||
* the contract and returns bare prose falls back to the raw content.
|
||||
*/
|
||||
export function parseSummaryFromContent(content: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(content) as { summary?: string };
|
||||
if (typeof parsed.summary === "string") return parsed.summary;
|
||||
} catch {
|
||||
// not JSON — fall through to the raw content
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist one status assessment cache row in the standard text-only
|
||||
* shape (`{ dateKey, locale, text, providerType, model, tokensUsed }`).
|
||||
* Returns the row's `createdAt` ISO string. The medication-compliance
|
||||
* generator writes a richer `details` shape (per-medication array) and
|
||||
* keeps its own `auditLog.create` call.
|
||||
*/
|
||||
export async function persistStatusInsight(args: {
|
||||
userId: string;
|
||||
cacheAction: string;
|
||||
todayKey: string;
|
||||
locale: SupportedLocale;
|
||||
text: string;
|
||||
providerType: string;
|
||||
model: string;
|
||||
tokensUsed: number | null;
|
||||
}): Promise<string> {
|
||||
const created = await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: args.userId,
|
||||
action: args.cacheAction,
|
||||
details: JSON.stringify({
|
||||
dateKey: args.todayKey,
|
||||
locale: args.locale,
|
||||
text: args.text,
|
||||
providerType: args.providerType,
|
||||
model: args.model,
|
||||
tokensUsed: args.tokensUsed,
|
||||
}),
|
||||
},
|
||||
select: { createdAt: true },
|
||||
});
|
||||
return created.createdAt.toISOString();
|
||||
}
|
||||
@@ -19,17 +19,24 @@ import {
|
||||
} from "@/lib/insights/bucket-series";
|
||||
import {
|
||||
buildGradedSeriesFromPoints,
|
||||
buildGradedSeriesWithRollups,
|
||||
degradeStatusSnapshotToBudget,
|
||||
} from "@/lib/insights/graded-series";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import {
|
||||
type SupportedLocale,
|
||||
normalizeLocale,
|
||||
normalizeSummaryText,
|
||||
parseSummaryFromContent,
|
||||
persistStatusInsight,
|
||||
round,
|
||||
summarizeSeries,
|
||||
} from "@/lib/insights/status-shared";
|
||||
import { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
import { readFreshStatusText } from "@/lib/insights/status-cache";
|
||||
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
|
||||
type SupportedLocale = "de" | "en";
|
||||
|
||||
/**
|
||||
* Cap on the embedded correlation pair arrays. The Pearson coefficient
|
||||
* is still computed over the full overlap; only the row-by-row pair
|
||||
@@ -37,49 +44,6 @@ type SupportedLocale = "de" | "en";
|
||||
*/
|
||||
const CORRELATION_PAIR_CAP = 30;
|
||||
|
||||
function round(value: number, digits = 1): number {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
function normalizeSummaryText(value: string): string {
|
||||
return stripChartTokens(value).replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function normalizeLocale(value: string | null | undefined): SupportedLocale {
|
||||
return value === "en" ? "en" : "de";
|
||||
}
|
||||
|
||||
function summarizeSeries(series: Array<{ value: number }>) {
|
||||
if (series.length === 0) return null;
|
||||
const first = series[0].value;
|
||||
const last = series[series.length - 1].value;
|
||||
// v1.4.33 — fold sum/min/max into a single walk. The previous
|
||||
// `Math.min(...series.map(...))` / `Math.max(...series.map(...))`
|
||||
// spread tripped V8's ~125 000-arg ceiling on the bound /api/analytics
|
||||
// path; see `.planning/round-v1433-analytics-500-report.md` §"Carry-
|
||||
// over". These helpers are fed bounded windows today so the crash
|
||||
// never reached them, but the spread allocates a transient args array
|
||||
// on every call — the fold is both stack-safe and cheaper.
|
||||
let sum = 0;
|
||||
let minVal = series[0].value;
|
||||
let maxVal = series[0].value;
|
||||
for (const entry of series) {
|
||||
sum += entry.value;
|
||||
if (entry.value < minVal) minVal = entry.value;
|
||||
if (entry.value > maxVal) maxVal = entry.value;
|
||||
}
|
||||
return {
|
||||
points: series.length,
|
||||
start: round(first, 2),
|
||||
end: round(last, 2),
|
||||
delta: round(last - first, 2),
|
||||
mean: round(sum / series.length, 2),
|
||||
min: round(minVal, 2),
|
||||
max: round(maxVal, 2),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair two daily-bucket series on `dayOffset`. The synthesised `date`
|
||||
* field is anchored at the UTC midnight of the Berlin calendar day —
|
||||
@@ -226,27 +190,17 @@ export async function generateWeightStatusForUser(
|
||||
// Compact graded series for the prompt. The `*Series.daily` buckets
|
||||
// above stay for the correlation pairing + latest-day focus; only the
|
||||
// graded shape is embedded so the full daily arrays never ship.
|
||||
const weightGraded = buildGradedSeriesFromPoints(
|
||||
weightSeries.daily.map((b) => ({
|
||||
measuredAt: new Date(dayOffsetToBerlinDayKey(now, b.dayOffset)),
|
||||
value: b.value,
|
||||
})),
|
||||
now,
|
||||
);
|
||||
const sysGraded = buildGradedSeriesFromPoints(
|
||||
sysSeries.daily.map((b) => ({
|
||||
measuredAt: new Date(dayOffsetToBerlinDayKey(now, b.dayOffset)),
|
||||
value: b.value,
|
||||
})),
|
||||
now,
|
||||
);
|
||||
const diaGraded = buildGradedSeriesFromPoints(
|
||||
diaSeries.daily.map((b) => ({
|
||||
measuredAt: new Date(dayOffsetToBerlinDayKey(now, b.dayOffset)),
|
||||
value: b.value,
|
||||
})),
|
||||
now,
|
||||
);
|
||||
//
|
||||
// Weight + the two BP context channels source their recent / weekly
|
||||
// slices from a bounded raw read and their monthly / yearly tail from
|
||||
// the MONTH / YEAR rollup tier (with a full-history in-memory fallback
|
||||
// on a cold-tier coverage miss). Mood has no rollup tier, so it stays
|
||||
// an in-memory fold.
|
||||
const [weightGraded, sysGraded, diaGraded] = await Promise.all([
|
||||
buildGradedSeriesWithRollups(userId, "WEIGHT", now),
|
||||
buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_SYS", now),
|
||||
buildGradedSeriesWithRollups(userId, "BLOOD_PRESSURE_DIA", now),
|
||||
]);
|
||||
const moodGraded = buildGradedSeriesFromPoints(
|
||||
moodSeries.daily.map((b) => ({
|
||||
measuredAt: new Date(dayOffsetToBerlinDayKey(now, b.dayOffset)),
|
||||
@@ -464,44 +418,27 @@ export async function generateWeightStatusForUser(
|
||||
});
|
||||
}
|
||||
|
||||
let summary = "";
|
||||
try {
|
||||
const parsed = JSON.parse(outcome.content) as { summary?: string };
|
||||
if (typeof parsed.summary === "string") {
|
||||
summary = parsed.summary;
|
||||
} else {
|
||||
summary = outcome.content;
|
||||
}
|
||||
} catch {
|
||||
summary = outcome.content;
|
||||
}
|
||||
|
||||
summary = normalizeSummaryText(summary);
|
||||
const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content));
|
||||
if (!summary) {
|
||||
throw new Error("Weight-status summary was empty after normalization");
|
||||
}
|
||||
|
||||
const created = await prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
action: cacheAction,
|
||||
details: JSON.stringify({
|
||||
dateKey: todayKey,
|
||||
locale,
|
||||
text: summary,
|
||||
providerType: outcome.providerType,
|
||||
model: outcome.model,
|
||||
tokensUsed: outcome.tokensUsed,
|
||||
}),
|
||||
},
|
||||
select: { createdAt: true },
|
||||
const updatedAt = await persistStatusInsight({
|
||||
userId,
|
||||
cacheAction,
|
||||
todayKey,
|
||||
locale,
|
||||
text: summary,
|
||||
providerType: outcome.providerType,
|
||||
model: outcome.model,
|
||||
tokensUsed: outcome.tokensUsed,
|
||||
});
|
||||
|
||||
return {
|
||||
hasProvider: true,
|
||||
text: summary,
|
||||
cached: false,
|
||||
updatedAt: created.createdAt.toISOString(),
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user