diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000000..831acbd05e --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,30 @@ +# Nakafa Context + +This glossary records stable domain terms used by Nakafa code and PR review. It is not an implementation plan. + +## Learning Engagement + +- **Canonical asset**: A material, question, article, or Quran asset owned by the content system. Other product surfaces group or navigate over references to these assets. +- **Learning context**: The verified page, placement, and tool policy facts available for one user interaction. +- **NinaContextPack**: The immutable learning context snapshot built before one Nina turn and stored on chat messages for replay. +- **Continue Learning**: A signed-in user read model ranked from recent learning interactions. It must not be inferred for anonymous users. +- **Popularity**: Aggregate learning interest derived from view events and durable counters. Product reads use bounded read models, not raw event scans. +- **Lifetime counter**: A durable popularity count that continues after raw audit events expire. +- **Integrity Module**: Permanent operational code that proves raw event coverage, checkpoint progress, lifetime counter inclusion, and rank-index consistency. + +## Nina + +- **NinaHarness**: The package-owned Effect service with the only app-facing `stream` Interface for Nina chat turns. +- **LearningCapability**: An internal education Module Nina can invoke for bounded evidence such as Nakafa retrieval, deterministic math, or external research. +- **Evidence**: Schema-derived facts, calculations, citations, content references, and limitations that constrain Nina's answer. +- **EvidenceEnvelope**: The schema-derived LearningCapability result that carries status, compact model-visible evidence, references, and limitations. +- **CapabilityTrace**: A bounded operational summary of LearningCapability execution for support, integrity checks, and evals. It is not a raw transcript. +- **Capability policy**: The per-turn decision that returns Allowed, Denied, or NeedsConfirmation for a LearningCapability. +- **Pinned context**: The latest stored NinaContextPack reused when a continued chat is opened away from a verified learning asset. +- **Page fetch**: The one permitted current-page Nakafa content read for a verified learning page. + +## Evaluation + +- **EvalCase**: A schema-derived test input with deterministic expected evidence, routing, or trace assertions. +- **EvalSuite**: A named collection of EvalCases for one NinaHarness or LearningCapability behavior boundary. +- **EvalRun**: A recorded execution of an EvalSuite with bounded evidence and trace summaries. diff --git a/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/materials/[subject]/[topic]/[[...lesson]]/data.ts b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/materials/[subject]/[topic]/[[...lesson]]/data.ts index dd0baa3b28..c68013a21c 100644 --- a/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/materials/[subject]/[topic]/[[...lesson]]/data.ts +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/materials/[subject]/[topic]/[[...lesson]]/data.ts @@ -2,7 +2,9 @@ import { getMaterialIcon } from "@repo/contents/_lib/curriculum/material"; import { isMaterialContentRoute, isMaterialLessonRoute, + readMaterialPagination, readParentMaterialRoute, + toLocalizedContentHref, } from "@repo/contents/_types/route/content"; import { readStaticPublicContentRoutes } from "@repo/contents/_types/route/content/static"; import { readStaticPublicLearningIndex } from "@repo/contents/_types/route/learning/static"; @@ -139,6 +141,33 @@ export function readMaterialHeaderLink( }); } +/** + * Builds material sibling pagination while preserving a validated source context. + * + * Canonical visits and stale context hints use plain material URLs. Contextual + * visits keep the same source card identity on previous/next lesson links only + * when the current page and target sibling both validate against the index. + */ +export function readMaterialPagePagination( + route: PublicContentRoute, + context: MaterialContextIdentity | undefined +) { + const index = readStaticPublicLearningIndex(); + + if (!(context && index.resolveMaterialHeaderLink({ context, route }))) { + return readMaterialPagination(route, readMaterialRoutes()); + } + + return readMaterialPagination(route, readMaterialRoutes(), { + toHref: (targetRoute) => + index.toContextualMaterialHref({ + context, + href: toLocalizedContentHref(targetRoute), + route: targetRoute, + }), + }); +} + /** * Selects the subject-specific icon from one projected material source path. * diff --git a/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/materials/[subject]/[topic]/[[...lesson]]/layout.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/materials/[subject]/[topic]/[[...lesson]]/layout.tsx deleted file mode 100644 index 93a19dc3a7..0000000000 --- a/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/materials/[subject]/[topic]/[[...lesson]]/layout.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import type { ReactNode } from "react"; -import { readMaterialRoute } from "@/app/[locale]/(app)/(shared)/(main)/(learn)/materials/[subject]/[topic]/[[...lesson]]/data"; -import { ContentViewTracker } from "@/components/tracking/tracker"; -import { getRuntimeContentViewId } from "@/lib/content/views"; - -type MaterialLayoutParams = - PageProps<"/[locale]/materials/[subject]/[topic]/[[...lesson]]">["params"]; - -/** - * Restores material content view tracking for canonical lesson pages. - * - * The tracker reads the generated public route row while MDX rendering still - * uses the source path. Topic hubs remain navigation pages and do not emit a - * lesson-body content view. - */ -export default async function Layout({ - children, - params, -}: { - children: ReactNode; - params: MaterialLayoutParams; -}) { - const { locale, route } = await readMaterialRoute(params); - - if (route?.kind !== "subject-lesson") { - return children; - } - - const contentId = await getRuntimeContentViewId({ - locale, - route: route.publicPath, - }); - - if (!contentId) { - return children; - } - - return ( - - {children} - - ); -} diff --git a/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/materials/[subject]/[topic]/[[...lesson]]/page.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/materials/[subject]/[topic]/[[...lesson]]/page.tsx index 9633716509..e97574f279 100644 --- a/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/materials/[subject]/[topic]/[[...lesson]]/page.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/materials/[subject]/[topic]/[[...lesson]]/page.tsx @@ -1,10 +1,11 @@ +import type { LearningContextInput } from "@repo/backend/convex/contents/context"; import { getHeadings } from "@repo/contents/_lib/toc"; import { formatContentDateISO } from "@repo/contents/_shared/date"; import { isMaterialLessonRoute, - readMaterialPagination, toLocalizedContentHref, } from "@repo/contents/_types/route/content"; +import type { MaterialContextIdentity } from "@repo/contents/_types/route/material/reference"; import type { PublicContentRoute } from "@repo/contents/_types/route/schema"; import { ArticleJsonLd } from "@repo/seo/json-ld/article"; import { BreadcrumbJsonLd } from "@repo/seo/json-ld/breadcrumb"; @@ -19,6 +20,7 @@ import { getProjectedMaterialIcon, listMaterialStaticParams, readMaterialHeaderLink, + readMaterialPagePagination, readMaterialRoutes, requireParentMaterialRoute, resolveMaterialRoute, @@ -33,8 +35,10 @@ import { LayoutMaterialContent } from "@/components/shared/material/content"; import { LayoutMaterial } from "@/components/shared/material/layout"; import { LayoutMaterialToc } from "@/components/shared/material/toc"; import { PaginationContent } from "@/components/shared/pagination-content"; +import { ContentViewTracker } from "@/components/tracking/tracker"; import { importContentModuleOrNull } from "@/lib/content/module"; import { fetchRuntimeCurriculumPage } from "@/lib/content/runtime/pages"; +import { getRuntimeContentViewId } from "@/lib/content/views"; import { readMaterialContextQuery } from "@/lib/routing/material/query"; import { getGithubUrl } from "@/lib/utils/github"; import { getOgUrl, getSocialMetadata } from "@/lib/utils/metadata"; @@ -100,7 +104,7 @@ export async function generateMetadata({ /** * Renders the canonical material lesson page. * - * Topic rows are grouping data for curriculum card pages. They intentionally do + * Topic rows are grouping data for curriculum card pages. They intentionally * do not render public pages, so the learner opens concrete material content * directly from a collapsible card. */ @@ -139,31 +143,47 @@ export default async function Page({ const Content = content.default; const parentRoute = requireParentMaterialRoute(route); + const materialContext = readMaterialContextQuery(query ?? {}); + const trackerContext: LearningContextInput | undefined = materialContext + ? { + mode: "placement", + nodeKey: materialContext.nodeKey, + programKey: materialContext.programKey, + } + : undefined; + const contentId = await getRuntimeContentViewId({ + locale, + route: route.publicPath, + }); return ( - } - headerLink={readMaterialHeaderLink( - route, - readMaterialContextQuery(query ?? {}) - )} + - } > - - + } + headerLink={readMaterialHeaderLink(route, materialContext)} + locale={locale} + materialContext={materialContext} + parentTitle={parentRoute.title} + route={route} + toolbar={ + + } + > + + + ); } @@ -179,6 +199,7 @@ async function MaterialLessonPage({ footer, headerLink, locale, + materialContext, parentTitle, route, toolbar, @@ -191,6 +212,7 @@ async function MaterialLessonPage({ label: string; }; locale: Locale; + materialContext: MaterialContextIdentity | undefined; parentTitle: string; route: PublicContentRoute; toolbar: ReactNode; @@ -200,7 +222,7 @@ async function MaterialLessonPage({ const raw = content.body; const headings = getHeadings(raw); const metadata = content.metadata; - const pagination = readMaterialPagination(route, readMaterialRoutes()); + const pagination = readMaterialPagePagination(route, materialContext); const publishedAt = Option.getOrElse( formatContentDateISO(metadata.date), () => metadata.date diff --git a/apps/www/app/api/chat/content.test.ts b/apps/www/app/api/chat/content.test.ts index abcd8ecff8..143f241ec9 100644 --- a/apps/www/app/api/chat/content.test.ts +++ b/apps/www/app/api/chat/content.test.ts @@ -1,272 +1,13 @@ -// @vitest-environment node -import type { NakafaDataPart } from "@repo/ai/schema/data"; -import type { MyUIMessage } from "@repo/ai/types/message"; -import { resolveNakafaContentRef } from "@repo/contents/_lib/agent/refs"; -import { NakafaAgentContentRefInputSchema } from "@repo/contents/_lib/agent/schema/read"; -import { Effect, Option } from "effect"; import { describe, expect, it } from "vitest"; -import { - determinePageFetchNeed, - getCanonicalCurrentPageContentUrl, - getCanonicalNakafaContentUrl, - hasFetchedCurrentPageContent, -} from "@/app/api/chat/content"; - -const currentContentUrl = "/id/materi/matematika/integral/jumlahan-riemann"; - -type ContentStatus = Extract["status"]; - -/** - * Returns a retained assistant message with one content-fetch data part. - */ -function contentMessage({ - url, - status, -}: { - url: string; - status: ContentStatus; -}) { - const contentRef = getCanonicalNakafaContentUrl(url); - - if (status === "loading") { - return { - id: `message-${status}`, - role: "assistant", - parts: [ - { - id: `content-${status}`, - type: "data-nakafa", - data: { - kind: "content", - input: { - content_ref: NakafaAgentContentRefInputSchema.make(contentRef), - }, - status, - }, - }, - ], - } satisfies MyUIMessage; - } - - if (status === "error") { - return { - id: `message-${status}`, - role: "assistant", - parts: [ - { - id: `content-${status}`, - type: "data-nakafa", - data: { - kind: "content", - input: { - content_ref: NakafaAgentContentRefInputSchema.make(contentRef), - }, - status, - error: "Not found", - }, - }, - ], - } satisfies MyUIMessage; - } - - const parsedRef = Effect.runSync( - resolveNakafaContentRef(contentRef).pipe( - Effect.flatMap( - Option.match({ - onNone: () => - Effect.dieMessage(`Expected a valid content URL fixture: ${url}`), - onSome: (ref) => Effect.succeed(ref), - }) - ) - ) - ); - - return { - id: `message-${status}`, - role: "assistant", - parts: [ - { - id: `content-${status}`, - type: "data-nakafa", - data: { - kind: "content", - input: { - content_ref: NakafaAgentContentRefInputSchema.make(contentRef), - }, - status, - result: { - ...parsedRef, - title: "Rational Function", - description: "", - }, - }, - }, - ], - } satisfies MyUIMessage; -} +import { getCanonicalCurrentPageContentUrl } from "@/app/api/chat/content"; describe("app/api/chat/content", () => { - it("canonicalizes relative page URLs for content_ref inputs", () => { - expect(getCanonicalNakafaContentUrl(currentContentUrl)).toBe( - "https://nakafa.com/id/materi/matematika/integral/jumlahan-riemann" - ); - }); - - it("canonicalizes current-page chat route projections", () => { + it("builds the canonical current-page URL from locale and slug", () => { expect( getCanonicalCurrentPageContentUrl({ locale: "id", - slug: "/quran/1", + slug: "/materi/matematika/aljabar/", }) - ).toBe("https://nakafa.com/id/quran/1"); - }); - - it("keeps unknown locale prefixes as public URLs", () => { - expect( - getCanonicalNakafaContentUrl( - "/fr/subjects/mathematics/integral/riemann-sum" - ) - ).toBe("https://nakafa.com/fr/subjects/mathematics/integral/riemann-sum"); - }); - - it("keeps projected practice URLs on canonical public route refs", () => { - expect( - getCanonicalNakafaContentUrl( - "/en/practice/snbt/quantitative-knowledge/tryout-2026/set-1" - ) - ).toBe( - "https://nakafa.com/en/practice/snbt/quantitative-knowledge/tryout-2026/set-1" - ); - expect( - getCanonicalNakafaContentUrl( - "/en/practice/snbt/quantitative-knowledge/tryout-2026/set-1/question-9" - ) - ).toBe( - "https://nakafa.com/en/practice/snbt/quantitative-knowledge/tryout-2026/set-1/question-9" - ); - }); - - it("keeps curriculum context URLs as public navigation refs", () => { - expect( - getCanonicalNakafaContentUrl( - "/en/curriculum/merdeka/class-12/mathematics/integral" - ) - ).toBe( - "https://nakafa.com/en/curriculum/merdeka/class-12/mathematics/integral" - ); - }); - - it.each([ - [ - "same absolute URL", - `https://nakafa.com${currentContentUrl}`, - "done", - true, - ], - ["same relative URL", currentContentUrl, "done", true], - [ - "different URL", - "/id/materi/matematika/integral/integral-tentu", - "done", - false, - ], - ["loading fetch", currentContentUrl, "loading", false], - ["errored fetch", currentContentUrl, "error", false], - ] as const)("handles %s", (_, url, status, expected) => { - const messages = [contentMessage({ url, status })]; - - expect( - hasFetchedCurrentPageContent({ messages, url: currentContentUrl }) - ).toBe(expected); - }); - - it("ignores message parts that are not content fetches", () => { - const messages = [ - { - id: "assistant-text", - role: "assistant", - parts: [{ type: "text", text: "Previous answer" }], - }, - ] satisfies MyUIMessage[]; - - expect( - hasFetchedCurrentPageContent({ messages, url: currentContentUrl }) - ).toBe(false); - }); - - it("ignores Nakafa data parts for other kinds", () => { - const messages = [ - { - id: "assistant-search", - role: "assistant", - parts: [ - { - id: "search-1", - type: "data-nakafa", - data: { - kind: "search", - input: { - limit: 1, - locale: "id", - offset: 0, - queries: ["function"], - }, - status: "loading", - }, - }, - ], - }, - ] satisfies MyUIMessage[]; - - expect( - hasFetchedCurrentPageContent({ messages, url: currentContentUrl }) - ).toBe(false); - }); - - it("does not need a page fetch when the page is unverified", () => { - const needsPageFetch = determinePageFetchNeed({ - messages: [], - url: currentContentUrl, - verified: false, - }); - - expect(needsPageFetch).toBe(false); - }); - - it("needs a page fetch for a verified page without a retained fetch", () => { - const needsPageFetch = determinePageFetchNeed({ - messages: [], - url: currentContentUrl, - verified: true, - }); - - expect(needsPageFetch).toBe(true); - }); - - it("needs a page fetch for a verified canonical current-page URL", () => { - const url = getCanonicalCurrentPageContentUrl({ - locale: "id", - slug: "/quran/1", - }); - const needsPageFetch = determinePageFetchNeed({ - messages: [], - url, - verified: true, - }); - - expect(needsPageFetch).toBe(true); - }); - - it("does not need a page fetch after a retained successful fetch", () => { - const messages = [ - contentMessage({ url: currentContentUrl, status: "done" }), - ]; - const needsPageFetch = determinePageFetchNeed({ - messages, - url: currentContentUrl, - verified: true, - }); - - expect(needsPageFetch).toBe(false); + ).toBe("https://nakafa.com/id/materi/matematika/aljabar"); }); }); diff --git a/apps/www/app/api/chat/content.ts b/apps/www/app/api/chat/content.ts index c664771349..c324215c17 100644 --- a/apps/www/app/api/chat/content.ts +++ b/apps/www/app/api/chat/content.ts @@ -1,17 +1,7 @@ -import type { MyUIMessage } from "@repo/ai/types/message"; +import { getCanonicalNakafaContentUrl } from "@repo/ai/nina/runtime/page"; import type { Locale } from "@repo/contents/_types/content"; import { cleanSlug } from "@repo/utilities/helper"; -/** - * Converts an absolute or relative content URL into Nakafa's canonical origin. - */ -export function getCanonicalNakafaContentUrl(url: string) { - const parsedUrl = new URL(url, "https://nakafa.com"); - const slug = cleanSlug(parsedUrl.pathname); - - return `https://nakafa.com/${slug}`; -} - /** * Builds the canonical public Nakafa URL for the current chat page projection. */ @@ -24,70 +14,3 @@ export function getCanonicalCurrentPageContentUrl({ }) { return getCanonicalNakafaContentUrl(`/${locale}/${cleanSlug(slug)}`); } - -/** - * Normalizes absolute and relative content URLs to a comparable slug. - */ -function normalizeContentRefUrl(url: string) { - const canonicalUrl = getCanonicalNakafaContentUrl(url); - const parsedUrl = new URL(canonicalUrl, "https://nakafa.com"); - return cleanSlug(parsedUrl.pathname); -} - -/** - * Checks whether the retained chat context already contains a successful - * current-page content fetch for the normalized URL. - */ -export function hasFetchedCurrentPageContent({ - messages, - url, -}: { - messages: MyUIMessage[]; - url: string; -}) { - const currentUrl = normalizeContentRefUrl(url); - - for (const message of messages) { - for (const part of message.parts) { - if (part.type !== "data-nakafa") { - continue; - } - - if (part.data.kind !== "content") { - continue; - } - - if (part.data.status !== "done") { - continue; - } - - const resultUrl = normalizeContentRefUrl(part.data.result.url); - - if (resultUrl === currentUrl) { - return true; - } - } - } - - return false; -} - -/** - * Decides whether the verified current page still needs a content fetch. - */ -export function determinePageFetchNeed({ - messages, - url, - verified, -}: { - messages: MyUIMessage[]; - url: string; - verified: boolean; -}) { - if (!verified) { - return false; - } - - const fetched = hasFetchedCurrentPageContent({ messages, url }); - return !fetched; -} diff --git a/apps/www/app/api/chat/context.test.ts b/apps/www/app/api/chat/context.test.ts new file mode 100644 index 0000000000..3e4dac97c5 --- /dev/null +++ b/apps/www/app/api/chat/context.test.ts @@ -0,0 +1,193 @@ +// @vitest-environment node +import type { NinaContextSnapshot } from "@repo/ai/nina/memory/pack"; +import { LearningProgramKeySchema } from "@repo/contents/_types/program/schema"; +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; +import { resolveNinaLearningSession } from "@/app/api/chat/context"; + +const pinnedContext = { + capturedAt: "2026-06-21T00:00:00.000Z", + learning: { + assetId: "asset:id:material:chemistry:basic-laws:applications", + locale: "en", + slug: "subjects/chemistry/basic-chemistry-laws/chemistry-law-applications", + title: "Law Applications", + url: "https://nakafa.com/en/subjects/chemistry/basic-chemistry-laws/chemistry-law-applications", + verified: true, + }, + source: "current-page", + tools: { + allowDeepResearch: true, + allowMath: true, + allowNakafa: true, + allowPageFetch: true, + evidenceScope: "verified-page", + }, +} satisfies NinaContextSnapshot; + +const pinnedPlacementContext = { + ...pinnedContext, + placement: { + mode: "placement", + nodeKey: "class-10-chemistry-basic-chemistry-laws", + parentHref: + "/en/curriculum/merdeka/class-10/chemistry#basic-laws-of-chemistry", + parentTitle: "Basic Laws of Chemistry", + programKey: LearningProgramKeySchema.make("merdeka"), + }, +} satisfies NinaContextSnapshot; + +describe("app/api/chat/context", () => { + it("opens an unverified off-page turn as canonical when no pinned context exists", async () => { + const session = await Effect.runPromise( + resolveNinaLearningSession({ + capturedAt: "2026-06-22T00:00:00.000Z", + locale: "en", + rawContext: "not-a-client-context", + slug: "/chat", + url: "https://nakafa.com/en/chat", + verified: false, + }) + ); + + expect(session.context.snapshot).toMatchObject({ + learning: { + locale: "en", + slug: "chat", + url: "https://nakafa.com/en/chat", + verified: false, + }, + source: "current-page", + tools: { + allowPageFetch: false, + evidenceScope: "general-learning", + }, + }); + expect(session.context.transition).toEqual({ + reason: "page-context", + toContextKey: "canonical:chat", + }); + }); + + it("reuses stored Nina context when an existing chat continues off a verified learning page", async () => { + const session = await Effect.runPromise( + resolveNinaLearningSession({ + capturedAt: "2026-06-22T00:00:00.000Z", + locale: "en", + pinnedContext, + rawContext: {}, + slug: "/chat/chat_existing", + url: "https://nakafa.com/en/chat/chat_existing", + verified: false, + }) + ); + + expect(session.context.snapshot).toMatchObject({ + capturedAt: "2026-06-22T00:00:00.000Z", + learning: pinnedContext.learning, + source: "pinned-chat", + tools: { + allowPageFetch: true, + evidenceScope: "verified-page", + }, + }); + expect(session.context.transition).toEqual({ + reason: "same-context", + toContextKey: + "canonical:subjects/chemistry/basic-chemistry-laws/chemistry-law-applications", + }); + }); + + it("preserves pinned placement when replaying an existing chat off-page", async () => { + const session = await Effect.runPromise( + resolveNinaLearningSession({ + capturedAt: "2026-06-22T00:00:00.000Z", + locale: "en", + pinnedContext: pinnedPlacementContext, + rawContext: {}, + slug: "/chat/chat_existing", + url: "https://nakafa.com/en/chat/chat_existing", + verified: false, + }) + ); + + expect(session.context.snapshot).toMatchObject({ + capturedAt: "2026-06-22T00:00:00.000Z", + learning: pinnedContext.learning, + placement: pinnedPlacementContext.placement, + source: "pinned-chat", + }); + expect(session.context.transition).toEqual({ + reason: "same-context", + toContextKey: + "placement:merdeka:class-10-chemistry-basic-chemistry-laws:subjects/chemistry/basic-chemistry-laws/chemistry-law-applications", + }); + }); + + it("keeps verified material placement when the browser context hint validates", async () => { + const session = await Effect.runPromise( + resolveNinaLearningSession({ + capturedAt: "2026-06-22T00:00:00.000Z", + locale: "en", + rawContext: { + materialContextHint: + "merdeka~class-10-chemistry-basic-chemistry-laws", + }, + slug: "/subjects/chemistry/basic-chemistry-laws/chemistry-law-applications", + url: "https://nakafa.com/en/subjects/chemistry/basic-chemistry-laws/chemistry-law-applications", + verified: true, + }) + ); + + expect(session.context.snapshot).toMatchObject({ + learning: { + materialKey: "lesson.chemistry.basic-chemistry-laws", + section: "subject-lesson", + sourcePath: + "material/lesson/chemistry/basic-chemistry-laws/chemistry-law-applications", + title: "Law Applications", + verified: true, + }, + placement: { + mode: "placement", + nodeKey: "class-10-chemistry-basic-chemistry-laws", + parentHref: + "/en/curriculum/merdeka/class-10/chemistry#basic-laws-of-chemistry", + parentTitle: "Basic Laws of Chemistry", + programKey: "merdeka", + }, + source: "current-page", + tools: { + allowPageFetch: true, + evidenceScope: "verified-page", + }, + }); + expect(session.context.transition).toEqual({ + reason: "page-context", + toContextKey: + "placement:merdeka:class-10-chemistry-basic-chemistry-laws:subjects/chemistry/basic-chemistry-laws/chemistry-law-applications", + }); + }); + + it("drops a stale material context hint instead of inventing placement", async () => { + const session = await Effect.runPromise( + resolveNinaLearningSession({ + capturedAt: "2026-06-22T00:00:00.000Z", + locale: "en", + rawContext: { + materialContextHint: "merdeka~stale-node", + }, + slug: "/subjects/chemistry/basic-chemistry-laws/chemistry-law-applications", + url: "https://nakafa.com/en/subjects/chemistry/basic-chemistry-laws/chemistry-law-applications", + verified: true, + }) + ); + + expect(session.context.snapshot.placement).toBeUndefined(); + expect(session.context.transition).toEqual({ + reason: "page-context", + toContextKey: + "canonical:subjects/chemistry/basic-chemistry-laws/chemistry-law-applications", + }); + }); +}); diff --git a/apps/www/app/api/chat/context.ts b/apps/www/app/api/chat/context.ts new file mode 100644 index 0000000000..855b045845 --- /dev/null +++ b/apps/www/app/api/chat/context.ts @@ -0,0 +1,206 @@ +import type { + NinaContextSnapshot, + NinaLearningSessionInput, +} from "@repo/ai/nina/memory/pack"; +import { + NinaContextSnapshotSchema, + openNinaLearningSession, +} from "@repo/ai/nina/memory/pack"; +import { type Locale, LocaleSchema } from "@repo/contents/_types/content"; +import { createLearningGraphIdentityFromRoute } from "@repo/contents/_types/learning-graph"; +import { LearningProgramKeySchema } from "@repo/contents/_types/program/schema"; +import { readStaticPublicLearningIndex } from "@repo/contents/_types/route/learning/static"; +import { readMaterialContextHint } from "@repo/contents/_types/route/material/context"; +import type { PublicRoute } from "@repo/contents/_types/route/schema"; +import { cleanSlug } from "@repo/utilities/helper"; +import { Effect, Option, Schema } from "effect"; + +const ClientNinaContextInputSchema = Schema.Struct({ + materialContextHint: Schema.optional(Schema.NullOr(Schema.String)), +}).pipe(Schema.mutable); + +/** Route-bound facts needed to open one validated Nina learning session. */ +const ResolveNinaLearningSessionInputSchema = Schema.Struct({ + capturedAt: Schema.String, + locale: LocaleSchema, + pinnedContext: Schema.optional(NinaContextSnapshotSchema), + rawContext: Schema.Unknown, + slug: Schema.String, + url: Schema.String, + verified: Schema.Boolean, +}).pipe(Schema.mutable); + +type ClientNinaContextInput = Schema.Schema.Type< + typeof ClientNinaContextInputSchema +>; +type ResolveNinaLearningSessionInput = Schema.Schema.Type< + typeof ResolveNinaLearningSessionInputSchema +>; + +/** Decodes the optional browser-provided Nina context payload. */ +function readClientNinaContextInput(value: unknown): ClientNinaContextInput { + const decoded = Schema.decodeUnknownOption(ClientNinaContextInputSchema)( + value + ); + + if (Option.isNone(decoded)) { + return {}; + } + + return decoded.value; +} + +/** Returns whether a public route row owns canonical source content. */ +function hasSourceContent( + route: PublicRoute | undefined +): route is Extract { + return Boolean(route && "sourcePath" in route); +} + +/** Builds Nina's verified learning context from a public route projection. */ +function createNinaLearningContext({ + locale, + route, + slug, + url, + verified, +}: { + locale: Locale; + route: PublicRoute | undefined; + slug: string; + url: string; + verified: boolean; +}): NinaLearningSessionInput["learning"] { + if (!hasSourceContent(route)) { + return { + locale, + slug, + url, + verified, + }; + } + + const graph = createLearningGraphIdentityFromRoute({ + locale, + route: route.sourcePath, + }); + + return { + assetId: graph?.assetId, + contentId: graph?.assetId, + locale, + materialKey: route.materialKey, + section: route.kind, + slug, + sourcePath: route.sourcePath, + title: route.title, + url, + verified, + }; +} + +/** Builds Nina's placement context only when the material ctx hint validates. */ +function createNinaPlacementContext({ + clientContext, + route, +}: { + clientContext: ClientNinaContextInput; + route: PublicRoute | undefined; +}): NinaLearningSessionInput["placement"] { + if (!hasSourceContent(route)) { + return; + } + + const context = readMaterialContextHint(clientContext.materialContextHint); + const header = readStaticPublicLearningIndex().resolveMaterialHeaderLink({ + context, + route, + }); + const programKey = Schema.decodeUnknownOption(LearningProgramKeySchema)( + context?.programKey + ); + + if (!(context && header) || Option.isNone(programKey)) { + return; + } + + return { + mode: "placement", + nodeKey: context.nodeKey, + parentHref: header.href, + parentTitle: header.label, + programKey: programKey.value, + }; +} + +/** Builds NinaHarness input from the current app route and validated context. */ +function createNinaLearningSessionInput({ + capturedAt, + locale, + pinnedContext, + rawContext, + slug, + url, + verified, +}: ResolveNinaLearningSessionInput): NinaLearningSessionInput { + if (!verified && pinnedContext) { + return createPinnedNinaLearningSessionInput({ + capturedAt, + snapshot: pinnedContext, + }); + } + + const cleanPath = cleanSlug(slug); + const route = readStaticPublicLearningIndex().resolveRouteByPath( + cleanPath, + locale + ); + const clientContext = readClientNinaContextInput(rawContext); + const learning = createNinaLearningContext({ + locale, + route, + slug: cleanPath, + url, + verified, + }); + const placement = createNinaPlacementContext({ + clientContext, + route, + }); + + return { + capturedAt, + learning, + source: "current-page", + ...(placement ? { placement } : {}), + }; +} + +/** Builds NinaHarness input from the latest stored context in an existing chat. */ +function createPinnedNinaLearningSessionInput({ + capturedAt, + snapshot, +}: { + capturedAt: string; + snapshot: NinaContextSnapshot; +}): NinaLearningSessionInput { + return { + capturedAt, + learning: snapshot.learning, + source: "pinned-chat", + ...(snapshot.placement ? { placement: snapshot.placement } : {}), + }; +} + +/** Resolves one Effect-native Nina learning session for the chat route. */ +export const resolveNinaLearningSession = Effect.fn( + "chat.resolveNinaLearningSession" +)(function* (input: ResolveNinaLearningSessionInput) { + const routeInput = yield* Schema.decodeUnknown( + ResolveNinaLearningSessionInputSchema + )(input); + + return yield* openNinaLearningSession( + createNinaLearningSessionInput(routeInput) + ); +}); diff --git a/apps/www/app/api/chat/failure.ts b/apps/www/app/api/chat/failure.ts index 25285f7607..ce7114f2d9 100644 --- a/apps/www/app/api/chat/failure.ts +++ b/apps/www/app/api/chat/failure.ts @@ -5,13 +5,6 @@ import type { Id } from "@repo/backend/convex/_generated/dataModel"; import { fetchAction } from "convex/nextjs"; import { Effect } from "effect"; -interface PersistAssistantFailure { - chatId: Id<"chats">; - modelId: ModelId; - responseMessageId: string; - token: string; -} - /** * Schedules a durable failed assistant marker through Convex. * @@ -24,7 +17,12 @@ export const persistAssistantFailure = Effect.fn( modelId, responseMessageId, token, -}: PersistAssistantFailure) { +}: { + readonly chatId: Id<"chats">; + readonly modelId: ModelId; + readonly responseMessageId: string; + readonly token: string; +}) { yield* Effect.tryPromise({ try: () => fetchAction( diff --git a/apps/www/app/api/chat/observability.ts b/apps/www/app/api/chat/observability.ts index c37a84e2aa..684d9f2c3f 100644 --- a/apps/www/app/api/chat/observability.ts +++ b/apps/www/app/api/chat/observability.ts @@ -9,13 +9,6 @@ import { Effect } from "effect"; const source = "chat-api"; -interface ChatErrorReporterParams { - chatId: Id<"chats">; - logContext: LogContext; - modelId: ModelId; - userId: string; -} - /** Preserves real Error details while giving non-Error failures a stable shape. */ function toError(error: unknown) { if (error instanceof Error) { @@ -44,7 +37,12 @@ export function createChatErrorReporter({ logContext, modelId, userId, -}: ChatErrorReporterParams) { +}: { + readonly chatId: Id<"chats">; + readonly logContext: LogContext; + readonly modelId: ModelId; + readonly userId: string; +}) { const gatewayModelId = getModelGatewayId(modelId); return (error: unknown, errorLocation: string) => { diff --git a/apps/www/app/api/chat/persistence.test.ts b/apps/www/app/api/chat/persistence.test.ts index 6828f20a9a..8b466b23f2 100644 --- a/apps/www/app/api/chat/persistence.test.ts +++ b/apps/www/app/api/chat/persistence.test.ts @@ -1,9 +1,18 @@ // @vitest-environment node import { ModelIdSchema } from "@repo/ai/config/model"; +import type { + NinaContextSnapshot, + NinaContextTransition, +} from "@repo/ai/nina/memory/pack"; import type { MyUIMessage } from "@repo/ai/types/message"; import { Effect } from "effect"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { loadMessages, saveOrCreateChat } from "@/app/api/chat/persistence"; +import { + createChatWithMessage, + loadMessages, + loadPinnedNinaContext, + saveChatMessage, +} from "@/app/api/chat/persistence"; const mocks = vi.hoisted(() => ({ compressMessages: vi.fn(), @@ -36,16 +45,45 @@ const message = { role: "user", } satisfies MyUIMessage; const modelId = ModelIdSchema.make("nakafa-lite"); +const ninaContextSnapshot = { + capturedAt: "2026-05-09T00:00:00.000Z", + learning: { + locale: "id", + slug: "materi/matematika/integral/jumlahan-riemann", + url: "https://nakafa.com/id/materi/matematika/integral/jumlahan-riemann", + verified: true, + }, + source: "current-page", + tools: { + allowDeepResearch: true, + allowMath: true, + allowNakafa: true, + allowPageFetch: true, + evidenceScope: "verified-page", + }, +} satisfies NinaContextSnapshot; +const ninaContextTransition = { + reason: "page-context", + toContextKey: "canonical:materi/matematika/integral/jumlahan-riemann", +} satisfies NinaContextTransition; + +/** Adds the required Nina context fields for chat persistence tests. */ +function withNinaContext() { + return { + ninaContextSnapshot, + ninaContextTransition, + }; +} /** Returns one typed chat ID through the public persistence path. */ async function savedChatId() { mocks.fetchMutation.mockResolvedValueOnce({ chatId: "chat_existing" }); const chatId = await Effect.runPromise( - saveOrCreateChat({ - chatId: undefined, + createChatWithMessage({ message, modelId, + ...withNinaContext(), token: "session-token", }) ); @@ -71,10 +109,10 @@ describe("app/api/chat/persistence", () => { mocks.fetchMutation.mockResolvedValue({ chatId: "chat_new" }); const chatId = await Effect.runPromise( - saveOrCreateChat({ - chatId: undefined, + createChatWithMessage({ message, modelId, + ...withNinaContext(), token: "session-token", }) ); @@ -86,6 +124,8 @@ describe("app/api/chat/persistence", () => { message: { identifier: "message-1", modelId, + ninaContextSnapshot, + ninaContextTransition, role: "user", }, parts: [], @@ -100,10 +140,11 @@ describe("app/api/chat/persistence", () => { mocks.fetchQuery.mockResolvedValue(null); const result = await Effect.runPromise( - saveOrCreateChat({ + saveChatMessage({ chatId, message, modelId, + ...withNinaContext(), token: "session-token", }) ); @@ -116,6 +157,8 @@ describe("app/api/chat/persistence", () => { chatId, identifier: "message-1", modelId, + ninaContextSnapshot, + ninaContextTransition, role: "user", }, parts: [], @@ -124,43 +167,57 @@ describe("app/api/chat/persistence", () => { ); }); - it("deletes an existing message rewrite batch before saving the replacement", async () => { + it("loads the newest stored Nina context for pinned-chat continuation", async () => { const chatId = await savedChatId(); - mocks.fetchQuery.mockResolvedValue({ creationTime: 123 }); - mocks.fetchMutation - .mockResolvedValueOnce({ hasMore: true }) - .mockResolvedValueOnce({ hasMore: false }) - .mockResolvedValueOnce({}); + mocks.fetchQuery.mockResolvedValue(ninaContextSnapshot); - await Effect.runPromise( - saveOrCreateChat({ + const result = await Effect.runPromise( + loadPinnedNinaContext({ chatId, - message, - modelId, + messageIdentifier: message.id, token: "session-token", }) ); - expect(mocks.fetchMutation).toHaveBeenNthCalledWith( - 1, + expect(result).toEqual(ninaContextSnapshot); + expect(mocks.fetchQuery).toHaveBeenCalledWith( expect.anything(), - { - chatId, - fromCreationTime: 123, - }, + { chatId, messageIdentifier: message.id }, { token: "session-token" } ); - expect(mocks.fetchMutation).toHaveBeenNthCalledWith( - 2, - expect.anything(), - { + }); + + it("ignores missing pinned Nina context instead of inventing chat context", async () => { + const chatId = await savedChatId(); + mocks.fetchQuery.mockResolvedValue(null); + + const result = await Effect.runPromise( + loadPinnedNinaContext({ chatId, - fromCreationTime: 123, - }, - { token: "session-token" } + messageIdentifier: message.id, + token: "session-token", + }) ); - expect(mocks.fetchMutation).toHaveBeenNthCalledWith( - 3, + + expect(result).toBeUndefined(); + }); + + it("saves an existing chat rewrite through one atomic Convex mutation", async () => { + const chatId = await savedChatId(); + + await Effect.runPromise( + saveChatMessage({ + chatId, + message, + modelId, + ...withNinaContext(), + token: "session-token", + }) + ); + + expect(mocks.fetchQuery).not.toHaveBeenCalled(); + expect(mocks.fetchMutation).toHaveBeenCalledTimes(1); + expect(mocks.fetchMutation).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ message: expect.objectContaining({ @@ -172,6 +229,38 @@ describe("app/api/chat/persistence", () => { ); }); + it("loads rewrite-aware pinned context before saving a replacement", async () => { + const chatId = await savedChatId(); + mocks.fetchQuery.mockResolvedValueOnce(ninaContextSnapshot); + + const pinnedContext = await Effect.runPromise( + loadPinnedNinaContext({ + chatId, + messageIdentifier: message.id, + token: "session-token", + }) + ); + await Effect.runPromise( + saveChatMessage({ + chatId, + message, + modelId, + ...withNinaContext(), + token: "session-token", + }) + ); + + expect(pinnedContext).toEqual(ninaContextSnapshot); + expect(mocks.fetchQuery.mock.invocationCallOrder[0]).toBeLessThan( + mocks.fetchMutation.mock.invocationCallOrder[0] + ); + expect(mocks.fetchQuery).toHaveBeenCalledWith( + expect.anything(), + { chatId, messageIdentifier: message.id }, + { token: "session-token" } + ); + }); + it("loads paginated messages until the page stream is done", async () => { const chatId = await savedChatId(); const newerMessage = { ...message, id: "newer" }; diff --git a/apps/www/app/api/chat/persistence.ts b/apps/www/app/api/chat/persistence.ts index 94b02beb27..399dd9a934 100644 --- a/apps/www/app/api/chat/persistence.ts +++ b/apps/www/app/api/chat/persistence.ts @@ -1,114 +1,165 @@ import type { ModelId } from "@repo/ai/config/model"; import { compressMessages } from "@repo/ai/lib/message"; +import type { + NinaContextSnapshot, + NinaContextTransition, +} from "@repo/ai/nina/memory/pack"; +import { NinaContextSnapshotSchema } from "@repo/ai/nina/memory/pack"; import type { MyUIMessage } from "@repo/ai/types/message"; import { api as convexApi } from "@repo/backend/convex/_generated/api"; import type { Id } from "@repo/backend/convex/_generated/dataModel"; import { CHAT_MESSAGES_PAGE_SIZE } from "@repo/backend/convex/chats/constants"; import { mapUIMessagePartsToDBParts } from "@repo/backend/convex/chats/messageParts/uiToDb"; -import type { MessageWithPartsDoc } from "@repo/backend/convex/chats/schema"; import { mapDBMessagesToUIMessages } from "@repo/backend/convex/chats/utils"; import { fetchMutation, fetchQuery } from "convex/nextjs"; -import { Effect } from "effect"; +import type { FunctionReturnType } from "convex/server"; +import { Effect, Option, Schema } from "effect"; -interface ChatMessagesPage { - continueCursor: string; - isDone: boolean; - page: MessageWithPartsDoc[]; -} - -interface Save { - chatId: Id<"chats"> | undefined; - message: MyUIMessage; - modelId: ModelId; - token: string; -} +/** Generated Convex page shape returned by the chat-message pagination query. */ +type ChatMessagesPage = FunctionReturnType< + typeof convexApi.chats.queries.loadMessagesPage +>; -interface Load { - chatId: Id<"chats">; - token: string; -} - -/** - * Persists the user message to an existing chat, or creates a new chat with - * the message if no chatId is provided. - * - * @returns The resolved chat ID (either the existing one or the newly created one). - */ -export const saveOrCreateChat = Effect.fn("chat.saveOrCreateChat")(function* ({ - chatId, +/** Maps one UI message into the Convex payload shared by create/save mutations. */ +function createPersistedMessage({ message, modelId, - token, -}: Save) { - const dbParts = mapUIMessagePartsToDBParts({ messageParts: message.parts }); - - if (chatId) { - const existingMessage = yield* Effect.tryPromise(() => - fetchQuery( - convexApi.chats.queries.getMessageMatch, - { - chatId, - identifier: message.id, - }, - { token } - ) - ); - - if (existingMessage) { - let hasMore = true; - - while (hasMore) { - const result = yield* Effect.tryPromise(() => - fetchMutation( - convexApi.chats.mutations.deleteMessageBatch, - { - chatId, - fromCreationTime: existingMessage.creationTime, - }, - { token } - ) - ); - - hasMore = result.hasMore; - } - } + ninaContextSnapshot, + ninaContextTransition, +}: { + readonly message: MyUIMessage; + readonly modelId: ModelId; + readonly ninaContextSnapshot: NinaContextSnapshot; + readonly ninaContextTransition: NinaContextTransition; +}) { + return { + identifier: message.id, + modelId, + ninaContextSnapshot, + ninaContextTransition, + role: message.role, + }; +} - yield* Effect.tryPromise(() => +/** Creates a new study chat with the first persisted user message. */ +export const createChatWithMessage = Effect.fn("chat.createChatWithMessage")( + function* ({ + message, + modelId, + ninaContextSnapshot, + ninaContextTransition, + token, + }: { + readonly message: MyUIMessage; + readonly modelId: ModelId; + readonly ninaContextSnapshot: NinaContextSnapshot; + readonly ninaContextTransition: NinaContextTransition; + readonly token: string; + }) { + const dbParts = mapUIMessagePartsToDBParts({ messageParts: message.parts }); + const result = yield* Effect.tryPromise(() => fetchMutation( - convexApi.chats.mutations.saveMessage, + convexApi.chats.mutations.createChatWithMessage, { - message: { - chatId, - role: message.role, - identifier: message.id, + type: "study", + message: createPersistedMessage({ + message, modelId, - }, + ninaContextSnapshot, + ninaContextTransition, + }), parts: dbParts, }, { token } ) ); - return chatId; + + return result.chatId; } +); + +/** + * Saves one user message to an existing chat after context has been resolved. + * + * Convex owns transcript rewrite replacement atomically by message identifier, + * so the app adapter never performs a separate delete before this mutation. + */ +export const saveChatMessage = Effect.fn("chat.saveChatMessage")(function* ({ + chatId, + message, + modelId, + ninaContextSnapshot, + ninaContextTransition, + token, +}: { + readonly chatId: Id<"chats">; + readonly message: MyUIMessage; + readonly modelId: ModelId; + readonly ninaContextSnapshot: NinaContextSnapshot; + readonly ninaContextTransition: NinaContextTransition; + readonly token: string; +}) { + const dbParts = mapUIMessagePartsToDBParts({ messageParts: message.parts }); - const result = yield* Effect.tryPromise(() => + yield* Effect.tryPromise(() => fetchMutation( - convexApi.chats.mutations.createChatWithMessage, + convexApi.chats.mutations.saveMessage, { - type: "study", message: { - role: message.role, - identifier: message.id, - modelId, + chatId, + ...createPersistedMessage({ + message, + modelId, + ninaContextSnapshot, + ninaContextTransition, + }), }, parts: dbParts, }, { token } ) ); - return result.chatId; + + return chatId; }); +/** + * Loads the newest stored Nina snapshot that can pin the incoming turn. + * + * Convex resolves existing message identifiers against the retained transcript, + * so rewrite tails cannot leak future context into the replacement message. + * Decoding happens here so app routing never replays unvalidated metadata. + */ +export const loadPinnedNinaContext = Effect.fn("chat.loadPinnedNinaContext")( + function* ({ + chatId, + messageIdentifier, + token, + }: { + readonly chatId: Id<"chats">; + readonly messageIdentifier: string; + readonly token: string; + }) { + const storedContext = yield* Effect.tryPromise(() => + fetchQuery( + convexApi.chats.queries.getPinnedNinaContextForTurn, + { chatId, messageIdentifier }, + { token } + ) + ); + + const decoded = Schema.decodeUnknownOption(NinaContextSnapshotSchema)( + storedContext + ); + + if (Option.isNone(decoded)) { + return; + } + + return decoded.value; + } +); + /** * Fetches a chat transcript page-by-page until the retained context is enough * for model input. Older pages stop loading once compression would trim them. @@ -118,7 +169,10 @@ export const saveOrCreateChat = Effect.fn("chat.saveOrCreateChat")(function* ({ export const loadMessages = Effect.fn("chat.loadMessages")(function* ({ chatId, token, -}: Load) { +}: { + readonly chatId: Id<"chats">; + readonly token: string; +}) { let cursor: string | null = null; let messages: MyUIMessage[] = []; diff --git a/apps/www/app/api/chat/recovery.ts b/apps/www/app/api/chat/recovery.ts deleted file mode 100644 index 174408cc27..0000000000 --- a/apps/www/app/api/chat/recovery.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { TOOL_NAMES } from "@repo/ai/agents/orchestrator/names"; -import { provider } from "@repo/ai/config/app"; -import { - defaultModel, - getFastModelProviderOptions, -} from "@repo/ai/config/model"; -import { gatewayProviderOptions } from "@repo/ai/config/routing"; -import { backgroundGenerationTimeout } from "@repo/ai/config/timeouts"; -import { logError } from "@repo/utilities/logging/effect"; -import type { LogContext } from "@repo/utilities/logging/types"; -import { - generateText, - InvalidToolInputError, - NoSuchToolError, - Output, - type ToolCallRepairFunction, - type ToolSet, -} from "ai"; -import { Effect } from "effect"; - -type ChatRecoveryOptions = Parameters>[0]; - -interface Params extends ChatRecoveryOptions { - needsPageFetch: boolean; - sessionLogger: LogContext; - url: string; -} - -/** - * Recovers invalid chat tool calls with deterministic page-fetch input when needed. - * - * @see https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling - * @see https://ai-sdk.dev/docs/reference/ai-sdk-core/output#output-object - */ -export const recoverChatToolCall = Effect.fn("chat.recoverChatToolCall")( - function* ({ - error, - inputSchema, - needsPageFetch, - sessionLogger, - toolCall, - tools, - url, - }: Params) { - yield* logError(error, { - ...sessionLogger, - errorLocation: "experimental_repairToolCall", - toolName: toolCall.toolName, - toolInput: toolCall.input, - errorType: error.name, - }); - - if (NoSuchToolError.isInstance(error)) { - yield* Effect.logWarning( - "Invalid tool name, not attempting recovery" - ).pipe(Effect.annotateLogs(sessionLogger)); - return null; - } - - if ( - needsPageFetch && - toolCall.toolName === TOOL_NAMES.nakafa && - InvalidToolInputError.isInstance(error) - ) { - yield* Effect.logInfo("Using server-derived Nakafa input").pipe( - Effect.annotateLogs(sessionLogger) - ); - return { - ...toolCall, - input: JSON.stringify( - { - deliverables: ["current page evidence"], - objective: "Read the current Nakafa page.", - request: url, - requirements: ["Use the current page URL."], - }, - null, - 2 - ), - }; - } - - const tool = tools[toolCall.toolName]; - if (!tool) { - yield* Effect.logWarning( - "Tool is unavailable, not attempting recovery" - ).pipe(Effect.annotateLogs(sessionLogger)); - return null; - } - - const schema = yield* Effect.tryPromise(() => inputSchema(toolCall)); - const { output: recoveredArgs } = yield* Effect.tryPromise({ - try: () => - generateText({ - model: provider.languageModel(defaultModel), - output: Output.object({ schema: tool.inputSchema }), - prompt: [ - `The model tried to call the tool "${toolCall.toolName}"` + - " with the following arguments:", - JSON.stringify(toolCall.input, null, 2), - "The tool accepts the following schema:", - JSON.stringify(schema, null, 2), - "Please fix the arguments.", - ].join("\n"), - providerOptions: { - gateway: gatewayProviderOptions, - google: getFastModelProviderOptions(defaultModel), - }, - timeout: backgroundGenerationTimeout, - }), - catch: (error) => error, - }); - - yield* Effect.logInfo("Tool call successfully recovered").pipe( - Effect.annotateLogs(sessionLogger) - ); - return { - ...toolCall, - input: JSON.stringify(recoveredArgs, null, 2), - }; - } -); diff --git a/apps/www/app/api/chat/route.ts b/apps/www/app/api/chat/route.ts index 1fc6582c3c..93cea78576 100644 --- a/apps/www/app/api/chat/route.ts +++ b/apps/www/app/api/chat/route.ts @@ -1,31 +1,33 @@ +import { NakafaSearch } from "@repo/ai/agents/nakafa/search"; +import { Nakafa } from "@repo/ai/agents/nakafa/service"; import { DEFAULT_LATITUDE, DEFAULT_LONGITUDE, } from "@repo/ai/clients/weather/client"; import { hasEnoughCredits, ModelIdSchema } from "@repo/ai/config/model"; -import { compressMessages } from "@repo/ai/lib/message"; +import { NinaHarness } from "@repo/ai/nina/harness/stream"; +import { NinaReporter } from "@repo/ai/nina/runtime/report"; +import { NinaStore } from "@repo/ai/nina/runtime/store"; import type { MyUIMessage } from "@repo/ai/types/message"; import type { Id } from "@repo/backend/convex/_generated/dataModel"; import { LocaleSchema } from "@repo/contents/_types/content"; import { CorsValidator } from "@repo/security/lib/cors-validator"; import { cleanSlug } from "@repo/utilities/helper"; import { geolocation } from "@vercel/functions"; -import { - convertToModelMessages, - createUIMessageStreamResponse, - generateId, - pruneMessages, -} from "ai"; import { Effect, Option, Schema } from "effect"; import { getTranslations } from "next-intl/server"; import { CHAT_ERRORS } from "@/app/api/chat/constants"; -import { - determinePageFetchNeed, - getCanonicalCurrentPageContentUrl, -} from "@/app/api/chat/content"; +import { getCanonicalCurrentPageContentUrl } from "@/app/api/chat/content"; +import { resolveNinaLearningSession } from "@/app/api/chat/context"; +import { search as nakafaSearch } from "@/app/api/chat/nakafa"; +import { nakafaContent } from "@/app/api/chat/nakafa-content"; import { createChatErrorReporter } from "@/app/api/chat/observability"; -import { loadMessages, saveOrCreateChat } from "@/app/api/chat/persistence"; -import { streamChat } from "@/app/api/chat/stream"; +import { + createChatWithMessage, + loadPinnedNinaContext, + saveChatMessage, +} from "@/app/api/chat/persistence"; +import { createNinaStore } from "@/app/api/chat/store"; import { getLearningProfile, getUserInfo, @@ -59,12 +61,8 @@ export const maxDuration = 300; * POST /api/chat * * Handles an incoming chat message from the client. Validates the request, - * gates on user credits, persists the user message, then streams the - * assistant response back using the AI SDK UI message stream protocol. - * - * After the stream finishes, two fire-and-forget tasks run via `waitUntil`: - * - Title generation (first message only) - * - Assistant response persistence and credit deduction + * gates on user credits, persists the user message, then delegates response + * streaming to the package-owned NinaHarness. * * @see https://ai-sdk.dev/docs/reference/ai-sdk-ui/convert-to-model-messages * @see https://ai-sdk.dev/docs/reference/ai-sdk-ui/create-ui-message-stream-response @@ -79,12 +77,14 @@ export function POST(req: Request) { const { message, id, + context, locale: rawLocale, slug, model: rawModel, }: { message: MyUIMessage | undefined; id: Id<"chats"> | undefined; + context: unknown; locale: unknown; slug: string; model: unknown; @@ -124,7 +124,8 @@ export function POST(req: Request) { url.includes(segment) ); - const currentDate = new Date().toLocaleString("en-US", { + const capturedAt = new Date(); + const currentDate = capturedAt.toLocaleString("en-US", { year: "numeric", month: "long", day: "numeric", @@ -148,13 +149,30 @@ export function POST(req: Request) { getUserInfo(token), getLearningProfile(token, locale), ]); - if (!hasEnoughCredits(userInfo.credits, selectedModel)) { return new Response(CHAT_ERRORS.INSUFFICIENT_CREDITS.code, { status: CHAT_ERRORS.INSUFFICIENT_CREDITS.status, }); } + const pinnedContext = + id && !verified + ? yield* loadPinnedNinaContext({ + chatId: id, + messageIdentifier: message.id, + token, + }) + : undefined; + const ninaSession = yield* resolveNinaLearningSession({ + capturedAt: capturedAt.toISOString(), + locale, + ...(pinnedContext ? { pinnedContext } : {}), + rawContext: context, + slug, + url, + verified, + }); + const logContext = { service: "chat-api", currentPage: { @@ -164,17 +182,32 @@ export function POST(req: Request) { verified, }, currentDate, + ninaContext: ninaSession.context.snapshot, userLocation, userRole: userInfo.role, url, }; - const chatId = yield* saveOrCreateChat({ - chatId: id, - message, - modelId: selectedModel, - token, - }); + let chatId: Id<"chats">; + + if (id) { + chatId = yield* saveChatMessage({ + chatId: id, + message, + modelId: selectedModel, + ninaContextSnapshot: ninaSession.context.snapshot, + ninaContextTransition: ninaSession.context.transition, + token, + }); + } else { + chatId = yield* createChatWithMessage({ + message, + modelId: selectedModel, + ninaContextSnapshot: ninaSession.context.snapshot, + ninaContextTransition: ninaSession.context.transition, + token, + }); + } const reportChatError = createChatErrorReporter({ chatId, logContext, @@ -182,79 +215,50 @@ export function POST(req: Request) { userId: userInfo.userId, }); - const messages = yield* loadMessages({ chatId, token }); - const isFirstMessage = messages.length === 1; - - const originalMessageCount = messages.length; - const { messages: compressedMessages, tokens } = - compressMessages(messages); - const needsPageFetch = determinePageFetchNeed({ - messages: compressedMessages, - url, - verified, - }); - - if (compressedMessages.length < originalMessageCount) { - yield* Effect.logWarning( - `Messages compressed from ${originalMessageCount} to ${compressedMessages.length} messages (${tokens} tokens) to stay within token limit` - ).pipe(Effect.annotateLogs(logContext)); - } else { - yield* Effect.logInfo( - `All ${originalMessageCount} messages fit within token limit (${tokens} tokens)` - ).pipe(Effect.annotateLogs(logContext)); - } - - const modelMessages = yield* Effect.tryPromise(() => - convertToModelMessages(compressedMessages) - ); - // Persist and render reasoning in UI, but do not feed historical - // assistant reasoning back into the next LLM call. AI SDK documents this - // as the supported way to reduce model context without deleting stored UI - // messages. - // https://ai-sdk.dev/docs/reference/ai-sdk-ui/prune-messages - // https://github.com/vercel/ai/blob/main/packages/ai/src/generate-text/prune-messages.ts - const finalMessages = pruneMessages({ - messages: modelMessages, - reasoning: "all", - }); - - yield* Effect.logInfo("Chat session started").pipe( - Effect.annotateLogs(logContext) - ); - const translate = yield* Effect.tryPromise(() => getTranslations({ locale, namespace: "Ai" }) ); - const chat = { - finalMessages, - id: chatId, - isFirstMessage, - messages: compressedMessages, - responseMessageId: generateId(), - token, - }; - const page = { - locale, - needsFetch: needsPageFetch, - slug, - url, - verified, - }; - const runtime = { - currentDate, - logContext, - modelId: selectedModel, - reportError: reportChatError, - translate, - }; - const user = { - info: userInfo, - learningProfile, - location: userLocation, - }; - const stream = streamChat({ chat, page, runtime, user }); - return createUIMessageStreamResponse({ stream }); + return yield* NinaHarness.stream({ + copy: { + errorMessage: translate("error-message"), + rateLimitMessage: translate("rate-limit-message"), + }, + page: { + locale, + needsFetch: false, + nina: ninaSession.context, + slug, + url, + verified, + }, + runtime: { + currentDate, + modelId: selectedModel, + }, + user: { + ...(learningProfile ? { learningProfile } : {}), + ...(userInfo.role ? { role: userInfo.role } : {}), + location: userLocation, + }, + }).pipe( + Effect.provide(NinaHarness.Default), + Effect.provideService( + NinaStore, + createNinaStore({ + chatId, + modelId: selectedModel, + reportError: reportChatError, + token, + }) + ), + Effect.provideService(NinaReporter, { + report: ({ error, source }) => + Effect.sync(() => reportChatError(error, source)), + }), + Effect.provideService(Nakafa, nakafaContent), + Effect.provideService(NakafaSearch, nakafaSearch) + ); }) ); } diff --git a/apps/www/app/api/chat/specialist.ts b/apps/www/app/api/chat/specialist.ts deleted file mode 100644 index 9c5a8d69e3..0000000000 --- a/apps/www/app/api/chat/specialist.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { createPrompt } from "@repo/ai/prompt/utils"; -import type { ToolName } from "@repo/ai/schema/tools"; -import type { LogContext } from "@repo/utilities/logging/types"; -import type { LanguageModelUsage } from "ai"; -import { Effect, Option } from "effect"; - -type AddUsage = ( - component: ToolName, - usage: LanguageModelUsage -) => Effect.Effect; - -export interface SpecialistResult { - text: string; - usage: Option.Option; -} - -interface RecoverSpecialistFailureParams { - component: ToolName; - error: unknown; - errorLocation: string; - reportError: (error: unknown, source: string) => void; -} - -interface RecordSpecialistUsageParams { - addUsage: AddUsage; - component: ToolName; - logContext: LogContext; - result: SpecialistResult; -} - -/** - * Converts a completed specialist response into the chat tool result shape. - */ -export function specialistSuccess({ - text, - usage, -}: { - text: string; - usage: LanguageModelUsage; -}): SpecialistResult { - return { - text, - usage: Option.some(usage), - }; -} - -/** - * Records specialist usage only when the model returned real usage data. - */ -export const recordSpecialistUsage = Effect.fn("chat.specialist.recordUsage")( - function* ({ - addUsage, - component, - logContext, - result, - }: RecordSpecialistUsageParams) { - yield* Effect.annotateCurrentSpan("component", component); - - if (Option.isNone(result.usage)) { - yield* Effect.logWarning("Specialist usage unavailable").pipe( - Effect.annotateLogs({ - ...logContext, - component, - type: "specialist_usage", - usageAvailable: false, - }) - ); - return; - } - - yield* addUsage(component, result.usage.value); - } -); - -/** - * Turns a specialist failure into model-facing recovery evidence. - * - * The returned text is a tool result for the orchestrator, not final user copy. - * - * @see https://effect.website/docs/observability/tracing/ - * @see https://effect.website/docs/observability/logging/ - */ -export const recoverSpecialistFailure = Effect.fn( - "chat.specialist.recoverFailure" -)(function* ({ - component, - error, - errorLocation, - reportError, -}: RecoverSpecialistFailureParams) { - const normalizedError = normalizeError(error); - - yield* Effect.annotateCurrentSpan("component", component); - yield* Effect.annotateCurrentSpan("errorLocation", errorLocation); - - reportError(normalizedError, errorLocation); - - return { - text: formatSpecialistFailure(component), - usage: Option.none(), - }; -}); - -/** Normalizes unknown thrown values into Error objects for structured logs. */ -function normalizeError(error: unknown) { - if (error instanceof Error) { - return error; - } - - return new Error(String(error)); -} - -/** - * Builds a compact model-facing status for a failed specialist call. - */ -function formatSpecialistFailure(component: ToolName) { - return createPrompt({ - taskContext: [ - "# Specialist Status", - "", - `- Specialist: ${component}`, - "- Status: error", - "- Evidence returned: none", - "- Usage returned: none", - "", - "# Final Answer Constraint", - "", - "Use only evidence already present in this conversation.", - "Do not invent facts from the failed specialist.", - "Do not copy this status block into the final answer.", - ].join("\n"), - }); -} diff --git a/apps/www/app/api/chat/step.ts b/apps/www/app/api/chat/step.ts deleted file mode 100644 index 16891e8d06..0000000000 --- a/apps/www/app/api/chat/step.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { TOOL_NAMES } from "@repo/ai/agents/orchestrator/names"; -import { getSourceReferencesFromMessages } from "@repo/ai/lib/source"; -import { createPrompt } from "@repo/ai/prompt/utils"; -import type { ModelMessage } from "ai"; -import { Effect } from "effect"; - -const firstStepNumber = 0; -const nakafaStep = { - activeTools: [TOOL_NAMES.nakafa], - toolChoice: { toolName: TOOL_NAMES.nakafa, type: "tool" as const }, -}; -const researchStep = { - activeTools: [TOOL_NAMES.deepResearch], - toolChoice: { toolName: TOOL_NAMES.deepResearch, type: "tool" as const }, -}; - -/** - * Chooses the first-step AI SDK tool policy for grounded chat answers. - * - * Page fetches must read Nakafa content first. Explicit external URLs must go - * through research. Other first-turn requests stay under the orchestrator - * prompt, so low-risk greetings can answer directly while factual, current, or - * mathematical requests still choose the required evidence path. - * - * @see https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling#preparestep-callback - * @see https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text - */ -export const prepareChatStep = Effect.fn("chat.prepareChatStep")( - ({ - messages, - needsPageFetch, - system, - stepNumber, - }: { - messages: ModelMessage[]; - needsPageFetch: boolean; - system: string; - stepNumber: number; - }) => - Effect.sync(() => { - if (stepNumber !== firstStepNumber) { - return { - messages, - system: [ - system, - createPrompt({ - taskContext: ` - # Continuation Source Policy - - Continue from the evidence already gathered in earlier steps. - Preserve every source constraint from the user request and the specialist evidence. - `, - toolUsageGuidelines: ` - # Continuation Tool Guidance - - Continue with the model's tool choice, using gathered evidence as the decision source. - - Call math before the final answer when: - - Nakafa selected educational math content. - - The final answer will include calculations, formulas, numeric answers, answer keys, or correctness claims. - - The math input must verify the exact example, exercise, answer key, and numeric claims that will appear in the final answer. - - Do not call math after Nakafa when: - - The content is a non-math lesson, Quran, article, or definition without calculation. - - The source summary contains no mathematical verification target. - - After math returns, do not switch to different mathematical content unless you call math again for that replacement content. - `, - outputFormatting: ` - # User-Facing Citation Format - - Cite external research sources inline in the exact sentence they support. - Use [text](url) links with concise, human-readable text. - Use only links already present in external research evidence or current page context. - Do not add product homepages, documentation links, or source links from memory. - When research evidence contains markdown links, preserve those links in the final answer for every claim that uses that evidence. - If the answer has sections or bullets built from source-backed research, each source-backed section or bullet must keep at least one supporting link. - Do not add Nakafa source labels, Nakafa domain links, or citation-style links for Nakafa-owned content. - Never show numeric citation markers such as [1] or [4, 21, 23] to users. - Convert any research citation indexes into markdown links using the cited source URLs. - Never append a final source, reference, citation, or bibliography section in any language. - Do not collect links at the end of the answer. - `, - }), - ].join("\n\n"), - }; - } - - if (needsPageFetch) { - return { ...nakafaStep, messages }; - } - - if (getSourceReferencesFromMessages(messages).length > 0) { - return { ...researchStep, messages }; - } - - return { messages }; - }) -); diff --git a/apps/www/app/api/chat/store.ts b/apps/www/app/api/chat/store.ts new file mode 100644 index 0000000000..ef2911a02a --- /dev/null +++ b/apps/www/app/api/chat/store.ts @@ -0,0 +1,135 @@ +import type { ModelId } from "@repo/ai/config/model"; +import { generateTitle } from "@repo/ai/features/title"; +import { + type CapabilityTrace as CapabilityTraceShape, + encodeCapabilityTrace, +} from "@repo/ai/nina/capability/spec"; +import type { NinaStore } from "@repo/ai/nina/runtime/store"; +import { NinaStoreError } from "@repo/ai/nina/runtime/store"; +import { api as convexApi } from "@repo/backend/convex/_generated/api"; +import type { Id } from "@repo/backend/convex/_generated/dataModel"; +import { mapUIMessagePartsToDBParts } from "@repo/backend/convex/chats/messageParts/uiToDb"; +import { waitUntil } from "@vercel/functions"; +import { fetchAction, fetchMutation } from "convex/nextjs"; +import { type Context, Effect } from "effect"; +import { persistAssistantFailure } from "@/app/api/chat/failure"; +import { loadMessages } from "@/app/api/chat/persistence"; + +/** + * Creates the Convex-backed persistence adapter for one Nina stream response. + * + * The adapter captures app-only deployment details so `@repo/ai` can own the + * harness lifecycle without importing generated Convex types or Vercel APIs. + */ +export function createNinaStore({ + chatId, + modelId, + reportError, + token, +}: { + readonly chatId: Id<"chats">; + readonly modelId: ModelId; + readonly reportError: (error: unknown, source: string) => void; + readonly token: string; +}): Context.Tag.Service { + return { + loadMessages: () => + loadMessages({ chatId, token }).pipe( + Effect.mapError( + () => + new NinaStoreError({ + message: "Unable to load chat messages for Nina.", + source: "loadMessages", + }) + ) + ), + saveAssistant: ({ context, responseMessage }) => + Effect.sync(() => { + const tokenData = responseMessage.metadata?.tokens; + + waitUntil( + Effect.runPromise( + Effect.tryPromise(() => + fetchAction( + convexApi.chats.actions.scheduleSaveAssistantResponse, + { + message: { + chatId, + identifier: responseMessage.id, + inputTokens: tokenData?.input ?? 0, + modelId, + ninaContextSnapshot: context.snapshot, + ninaContextTransition: context.transition, + outputTokens: tokenData?.output ?? 0, + role: responseMessage.role, + totalTokens: tokenData?.total ?? 0, + }, + parts: mapUIMessagePartsToDBParts({ + messageParts: responseMessage.parts, + }), + }, + { token } + ) + ).pipe( + Effect.catchAll((error) => + Effect.sync(() => reportError(error, "saveAssistantResponse")) + ) + ) + ) + ); + }), + saveFailure: ({ responseMessageId }) => + Effect.sync(() => { + waitUntil( + Effect.runPromise( + persistAssistantFailure({ + chatId, + modelId, + responseMessageId, + token, + }).pipe( + Effect.catchAll((error) => + Effect.sync(() => reportError(error, "saveAssistantFailure")) + ) + ) + ) + ); + }), + saveTrace: (trace: CapabilityTraceShape) => + Effect.tryPromise({ + try: () => + fetchMutation( + convexApi.chats.traces.mutations.save, + { chatId, trace: encodeCapabilityTrace(trace) }, + { token } + ), + catch: () => + new NinaStoreError({ + message: "Unable to save Nina capability trace.", + source: "saveTrace", + }), + }).pipe(Effect.asVoid), + saveTitle: ({ messages }) => + Effect.sync(() => { + waitUntil( + Effect.runPromise( + Effect.gen(function* () { + const title = yield* generateTitle({ messages }); + + yield* Effect.tryPromise(() => + fetchMutation( + convexApi.chats.mutations.updateChatTitle, + { chatId, title }, + { token } + ) + ); + }).pipe( + Effect.catchAll((error) => + Effect.sync(() => reportError(error, "generateTitle")) + ) + ) + ) + ); + }), + }; +} diff --git a/apps/www/app/api/chat/stream.ts b/apps/www/app/api/chat/stream.ts deleted file mode 100644 index d61c2bcb48..0000000000 --- a/apps/www/app/api/chat/stream.ts +++ /dev/null @@ -1,499 +0,0 @@ -import { runMathAgent } from "@repo/ai/agents/math/agent"; -import { runNakafaAgent } from "@repo/ai/agents/nakafa/agent"; -import { NakafaSearch } from "@repo/ai/agents/nakafa/search"; -import { Nakafa } from "@repo/ai/agents/nakafa/service"; -import { read as readNakafa } from "@repo/ai/agents/nakafa/tools/read"; -import { TOOL_NAMES } from "@repo/ai/agents/orchestrator/names"; -import { nakafaPrompt } from "@repo/ai/agents/orchestrator/prompt"; -import { runResearchAgent } from "@repo/ai/agents/research/agent"; -import { provider } from "@repo/ai/config/app"; -import { getModelProviderOptions, type ModelId } from "@repo/ai/config/model"; -import { gatewayProviderOptions } from "@repo/ai/config/routing"; -import { chatStreamTimeout } from "@repo/ai/config/timeouts"; -import { generateTitle } from "@repo/ai/features/title"; -import { getSourceReferencesFromMessages } from "@repo/ai/lib/source"; -import { - formatSpecialistToolTask, - mathToolInputSchema, - nakafaToolInputSchema, - researchToolInputSchema, -} from "@repo/ai/schema/tools"; -import type { MyUIMessage } from "@repo/ai/types/message"; -import { api as convexApi } from "@repo/backend/convex/_generated/api"; -import type { Id } from "@repo/backend/convex/_generated/dataModel"; -import { mapUIMessagePartsToDBParts } from "@repo/backend/convex/chats/messageParts/uiToDb"; -import type { Locale } from "@repo/backend/convex/lib/validators/contents"; -import { NakafaAgentContentRefInputSchema } from "@repo/contents/_lib/agent/schema/read"; -import { cleanSlug } from "@repo/utilities/helper"; -import type { LogContext } from "@repo/utilities/logging/types"; -import { waitUntil } from "@vercel/functions"; -import { - createUIMessageStream, - type ModelMessage, - smoothStream, - stepCountIs, - streamText, - tool, -} from "ai"; -import { fetchAction, fetchMutation } from "convex/nextjs"; -import { Effect } from "effect"; -import type { getTranslations } from "next-intl/server"; -import { getCanonicalNakafaContentUrl } from "@/app/api/chat/content"; -import { persistAssistantFailure } from "@/app/api/chat/failure"; -import { search as nakafaSearch } from "@/app/api/chat/nakafa"; -import { nakafaContent } from "@/app/api/chat/nakafa-content"; -import { recoverChatToolCall } from "@/app/api/chat/recovery"; -import { getAssistantResponseFailure } from "@/app/api/chat/response"; -import { - recordSpecialistUsage, - recoverSpecialistFailure, - specialistSuccess, -} from "@/app/api/chat/specialist"; -import { prepareChatStep } from "@/app/api/chat/step"; -import { writeSuggestions } from "@/app/api/chat/suggestions"; -import { trackUsage } from "@/app/api/chat/usage"; -import type { getLearningProfile, getUserInfo } from "@/app/api/chat/utils"; - -const MAX_ORCHESTRATOR_STEPS = 20; - -type Location = Parameters[0]["userLocation"]; -type Translator = Awaited>; -type LearningProfile = Effect.Effect.Success< - ReturnType ->; -type UserInfo = Effect.Effect.Success>; - -/** Fully prepared inputs needed to stream and persist one chat response. */ -interface Params { - chat: { - finalMessages: ModelMessage[]; - id: Id<"chats">; - isFirstMessage: boolean; - messages: MyUIMessage[]; - responseMessageId: string; - token: string; - }; - page: { - locale: Locale; - needsFetch: boolean; - slug: string; - url: string; - verified: boolean; - }; - runtime: { - currentDate: string; - logContext: LogContext; - modelId: ModelId; - reportError: (error: unknown, source: string) => void; - translate: Translator; - }; - user: { - info: UserInfo; - learningProfile: LearningProfile; - location: Location; - }; -} - -/** - * Streams one chat turn through the AI SDK UI message stream. - * - * @see https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling#streaming-tool-calls - * @see https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text#to-ui-message-stream - * @see https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-message-persistence - */ -export function streamChat({ chat, page, runtime, user }: Params) { - let failureScheduled = false; - - /** Records a durable failed assistant turn when the streamed generation fails. */ - const scheduleAssistantFailure = (error: unknown, errorLocation: string) => { - if (failureScheduled) { - return; - } - failureScheduled = true; - runtime.reportError(error, errorLocation); - - waitUntil( - Effect.runPromise( - persistAssistantFailure({ - chatId: chat.id, - modelId: runtime.modelId, - responseMessageId: chat.responseMessageId, - token: chat.token, - }).pipe( - Effect.catchAll((saveError) => - Effect.sync(() => - runtime.reportError(saveError, "saveAssistantFailure") - ) - ) - ) - ) - ); - }; - - return createUIMessageStream({ - generateId: () => chat.responseMessageId, - onError: (error) => { - scheduleAssistantFailure(error, "createUIMessageStream"); - - if (error instanceof Error) { - if (error.message.includes("Rate limit")) { - Effect.runFork( - Effect.logWarning("Rate limit exceeded in chat stream").pipe( - Effect.annotateLogs(runtime.logContext) - ) - ); - return runtime.translate("rate-limit-message"); - } - return error.message; - } - Effect.runFork( - Effect.logError("Unknown error in chat stream").pipe( - Effect.annotateLogs(runtime.logContext) - ) - ); - return runtime.translate("error-message"); - }, - originalMessages: chat.messages, - onFinish: ({ - finishReason, - isAborted, - messages: updatedMessages, - responseMessage, - }) => { - if (failureScheduled) { - return; - } - - const responseFailure = getAssistantResponseFailure({ - finishReason, - isAborted, - responseMessage, - }); - - if (responseFailure) { - scheduleAssistantFailure(responseFailure, "chatResponseFinalization"); - return; - } - - if (chat.isFirstMessage) { - waitUntil( - Effect.runPromise( - Effect.gen(function* () { - const title = yield* generateTitle({ messages: updatedMessages }); - - yield* Effect.tryPromise(() => - fetchMutation( - convexApi.chats.mutations.updateChatTitle, - { chatId: chat.id, title }, - { token: chat.token } - ) - ); - }).pipe( - Effect.catchAll((error) => - Effect.sync(() => - runtime.reportError(error, "generateTitle/updateChatTitle") - ) - ) - ) - ) - ); - } - - const tokenData = responseMessage.metadata?.tokens; - - waitUntil( - Effect.runPromise( - Effect.tryPromise(() => - fetchAction( - convexApi.chats.actions.scheduleSaveAssistantResponse, - { - message: { - chatId: chat.id, - role: responseMessage.role, - identifier: responseMessage.id, - modelId: runtime.modelId, - inputTokens: tokenData?.input ?? 0, - outputTokens: tokenData?.output ?? 0, - totalTokens: tokenData?.total ?? 0, - }, - parts: mapUIMessagePartsToDBParts({ - messageParts: responseMessage.parts, - }), - }, - { token: chat.token } - ) - ).pipe( - Effect.catchAll((error) => - Effect.sync(() => - runtime.reportError(error, "saveAssistantResponse") - ) - ) - ) - ) - ); - }, - /** Runs the main AI stream and merges UI message chunks into the writer. */ - execute: ({ writer }) => - Effect.runPromise( - Effect.gen(function* () { - const usage = yield* trackUsage(); - const context = { - currentDate: runtime.currentDate, - learningProfile: user.learningProfile ?? undefined, - url: page.url, - slug: cleanSlug(page.slug), - verified: page.verified, - needsPageFetch: page.needsFetch, - userRole: user.info.role ?? undefined, - }; - let fetchedPage = false; - - const system = nakafaPrompt({ - url: page.url, - currentPage: { - locale: page.locale, - slug: page.slug, - verified: page.verified, - }, - currentDate: runtime.currentDate, - learningProfile: user.learningProfile ?? undefined, - userLocation: user.location, - userRole: user.info.role ?? undefined, - }); - - const streamTextResult = streamText({ - model: provider.languageModel(runtime.modelId), - system, - messages: chat.finalMessages, - stopWhen: stepCountIs(MAX_ORCHESTRATOR_STEPS), - tools: { - [TOOL_NAMES.nakafa]: tool({ - description: - "Retrieve Nakafa educational evidence for lessons, study topics, current pages, articles, Quran references, examples, warmups, review tasks, tryout preparation, and structured exercises. Use this before math when content must be selected. Preserve requested deliverables in the structured input.", - inputSchema: nakafaToolInputSchema, - /** Runs the Nakafa specialist with one-time current-page fetch support. */ - execute: (input, { toolCallId }) => { - const needsPageFetch = context.needsPageFetch && !fetchedPage; - - if (needsPageFetch) { - fetchedPage = true; - } - - return Effect.runPromise( - Effect.gen(function* () { - if (needsPageFetch) { - const contentRef = getCanonicalNakafaContentUrl( - context.url - ); - - return yield* readNakafa({ - input: { - content_ref: - NakafaAgentContentRefInputSchema.make(contentRef), - }, - toolCallId, - writer, - }).pipe(Effect.provideService(Nakafa, nakafaContent)); - } - - const result = yield* runNakafaAgent({ - context: { ...context, needsPageFetch }, - locale: page.locale, - modelId: runtime.modelId, - nakafa: nakafaContent, - task: formatSpecialistToolTask(input), - writer, - }).pipe( - Effect.provideService(NakafaSearch, nakafaSearch), - Effect.map(specialistSuccess), - Effect.catchAll((error) => - recoverSpecialistFailure({ - component: TOOL_NAMES.nakafa, - error, - errorLocation: "runNakafaAgent", - reportError: runtime.reportError, - }) - ) - ); - - yield* recordSpecialistUsage({ - addUsage: usage.addUsage, - component: TOOL_NAMES.nakafa, - logContext: runtime.logContext, - result, - }); - - return result.text; - }) - ); - }, - }), - [TOOL_NAMES.deepResearch]: tool({ - description: - "Research external, official, current, latest, cited, or source-backed information with web search and source analysis.", - inputSchema: researchToolInputSchema, - /** Runs the external research specialist and records its token usage. */ - execute: (input, { messages, toolCallId }) => - Effect.runPromise( - Effect.gen(function* () { - const result = yield* runResearchAgent({ - context, - locale: page.locale, - modelId: runtime.modelId, - task: formatSpecialistToolTask(input), - sourceReferences: - getSourceReferencesFromMessages(messages), - toolCallId, - writer, - }).pipe( - Effect.map(specialistSuccess), - Effect.catchAll((error) => - recoverSpecialistFailure({ - component: TOOL_NAMES.deepResearch, - error, - errorLocation: "runResearchAgent", - reportError: runtime.reportError, - }) - ) - ); - - yield* recordSpecialistUsage({ - addUsage: usage.addUsage, - component: TOOL_NAMES.deepResearch, - logContext: runtime.logContext, - result, - }); - - return result.text; - }) - ), - }), - [TOOL_NAMES.math]: tool({ - description: - "Verify user-provided or retrieved math with deterministic evidence for arithmetic, algebra, equations, calculus, series, matrices, statistics, probability, geometry, and discrete math. Do not use this as the first or only source for educational practice content; use Nakafa first, then math verifies the selected content.", - inputSchema: mathToolInputSchema, - /** Runs the deterministic math specialist and records its token usage. */ - execute: (input) => - Effect.runPromise( - Effect.gen(function* () { - const result = yield* runMathAgent({ - context, - locale: page.locale, - modelId: runtime.modelId, - task: formatSpecialistToolTask(input), - writer, - }).pipe( - Effect.map(specialistSuccess), - Effect.catchAll((error) => - recoverSpecialistFailure({ - component: TOOL_NAMES.math, - error, - errorLocation: "runMathAgent", - reportError: runtime.reportError, - }) - ) - ); - - yield* recordSpecialistUsage({ - addUsage: usage.addUsage, - component: TOOL_NAMES.math, - logContext: runtime.logContext, - result, - }); - - return result.text; - }) - ), - }), - }, - prepareStep: ({ messages, stepNumber }) => - Effect.runSync( - prepareChatStep({ - messages, - needsPageFetch: page.needsFetch, - system, - stepNumber, - }) - ), - experimental_repairToolCall: (options) => - Effect.runPromise( - recoverChatToolCall({ - ...options, - needsPageFetch: context.needsPageFetch && !fetchedPage, - sessionLogger: runtime.logContext, - url: page.url, - }) - ), - experimental_transform: smoothStream({ - delayInMs: 20, - chunking: "word", - }), - providerOptions: { - gateway: gatewayProviderOptions, - google: getModelProviderOptions(runtime.modelId), - }, - timeout: chatStreamTimeout, - }); - - writer.merge( - streamTextResult.toUIMessageStream({ - sendReasoning: true, - sendStart: false, - messageMetadata: ({ part }) => { - if (part.type === "start") { - return { model: runtime.modelId }; - } - - if (part.type === "finish") { - return Effect.runSync( - usage.metadata({ - mainUsage: part.totalUsage, - modelId: runtime.modelId, - }) - ); - } - }, - onError: (error) => { - scheduleAssistantFailure(error, "toUIMessageStream"); - - if (error instanceof Error) { - if (error.message.includes("Rate limit")) { - Effect.runFork( - Effect.logWarning( - "Rate limit exceeded in message stream" - ).pipe(Effect.annotateLogs(runtime.logContext)) - ); - return runtime.translate("rate-limit-message"); - } - return error.message; - } - Effect.runFork( - Effect.logError("Unknown error in message stream").pipe( - Effect.annotateLogs(runtime.logContext) - ) - ); - return runtime.translate("error-message"); - }, - }) - ); - - // AI SDK result promises consume the stream as needed; the merged UI - // stream is already the client-facing consumer. - // https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text - const response = yield* Effect.tryPromise({ - try: () => streamTextResult.response, - catch: (error) => error, - }); - yield* writeSuggestions({ - locale: page.locale, - messages: [...chat.finalMessages, ...response.messages], - writer, - }).pipe( - Effect.catchAll((error) => - Effect.sync(() => runtime.reportError(error, "writeSuggestions")) - ) - ); - }) - ), - }); -} diff --git a/apps/www/app/api/chat/utils.test.ts b/apps/www/app/api/chat/utils.test.ts index 5170af3cb4..0a78bc5f28 100644 --- a/apps/www/app/api/chat/utils.test.ts +++ b/apps/www/app/api/chat/utils.test.ts @@ -119,7 +119,27 @@ describe("app/api/chat/utils", () => { getLearningProfile("test-token", "en") ); - expect(result).toEqual(learningProfile); + expect(result).toEqual({ + interests: ["exam-prep", "assessment-prep"], + planItems: [ + { + content_id: "asset:id:exercise:snbt:2026:set-2:1", + lensId: "lens:snbt", + position: 1, + route: + "/material/practice/assessment/snbt/general-knowledge/try-out-2026/set-2/question-1", + status: "ready", + title: "SNBT Set 2", + }, + ], + program: { + coverageStatus: "partial", + key: "snbt-2026", + kind: "admission-exam", + title: "SNBT 2026", + versionLabel: "2026", + }, + }); expect(fetchQuery).toHaveBeenCalledWith( convexApi.learningPrograms.queries.getActiveProfile, { locale: "en" }, diff --git a/apps/www/app/api/chat/utils.ts b/apps/www/app/api/chat/utils.ts index aff73733b5..002edf0082 100644 --- a/apps/www/app/api/chat/utils.ts +++ b/apps/www/app/api/chat/utils.ts @@ -1,7 +1,8 @@ +import { AgentLearningProfileSchema } from "@repo/ai/types/agents"; import { api as convexApi } from "@repo/backend/convex/_generated/api"; import type { Locale } from "@repo/utilities/locales"; import { fetchMutation, fetchQuery } from "convex/nextjs"; -import { Effect } from "effect"; +import { Effect, Schema } from "effect"; import { nakafaContent } from "@/app/api/chat/nakafa-content"; /** @@ -42,7 +43,7 @@ export const getUserInfo = Effect.fn("chat.getUserInfo")(function* ( */ export const getLearningProfile = Effect.fn("chat.getLearningProfile")( function* (token: string, locale: Locale) { - return yield* Effect.tryPromise(() => + const profile = yield* Effect.tryPromise(() => fetchQuery( convexApi.learningPrograms.queries.getActiveProfile, { locale }, @@ -51,5 +52,9 @@ export const getLearningProfile = Effect.fn("chat.getLearningProfile")( } ) ); + + return yield* Schema.decodeUnknown( + Schema.NullOr(AgentLearningProfileSchema) + )(profile); } ); diff --git a/apps/www/components/ai/helpers/runtime.ts b/apps/www/components/ai/helpers/runtime.ts index 87cd23a197..78a95a0c47 100644 --- a/apps/www/components/ai/helpers/runtime.ts +++ b/apps/www/components/ai/helpers/runtime.ts @@ -5,7 +5,11 @@ import type { MyUIMessage } from "@repo/ai/types/message"; import type { Id } from "@repo/backend/convex/_generated/dataModel"; import { DefaultChatTransport } from "ai"; import type { AiStore } from "@/components/ai/store/types"; -import { getLocale, getPathname } from "@/lib/utils/browser"; +import { + getLocale, + getMaterialContextHint, + getPathname, +} from "@/lib/utils/browser"; interface CreateChatRuntimeOptions { apiUrl?: string; @@ -36,6 +40,9 @@ export function createChatTransport({ locale: getLocale(), message: lastMessage, model: getModel(), + context: { + materialContextHint: getMaterialContextHint(), + }, slug: getPathname(), }, }; diff --git a/apps/www/components/home/continue-learning.tsx b/apps/www/components/home/continue-learning.tsx index b6eb8b3a3e..c7b6252b76 100644 --- a/apps/www/components/home/continue-learning.tsx +++ b/apps/www/components/home/continue-learning.tsx @@ -8,7 +8,6 @@ import { Button } from "@repo/design-system/components/ui/button"; import { GradientBlock } from "@repo/design-system/components/ui/gradient-block"; import { HugeIcons } from "@repo/design-system/components/ui/huge-icons"; import NavigationLink from "@repo/design-system/components/ui/navigation-link"; -import { cleanSlug } from "@repo/utilities/helper"; import { useLocale, useTranslations } from "next-intl"; /** Renders graph-backed recently viewed learning objects on the home screen. */ @@ -42,8 +41,8 @@ export function HomeContinueLearning() { {data.map((subject) => (
diff --git a/apps/www/components/home/trending.tsx b/apps/www/components/home/trending.tsx index 1167f43ce8..92373bec14 100644 --- a/apps/www/components/home/trending.tsx +++ b/apps/www/components/home/trending.tsx @@ -2,30 +2,24 @@ import { ArrowDown02Icon, ViewIcon } from "@hugeicons/core-free-icons"; import { api } from "@repo/backend/convex/_generated/api"; -import { getTrendingTimeRange } from "@repo/backend/convex/curriculumLessons/utils"; import { useQueryWithStatus } from "@repo/backend/helpers/react"; import { getMaterialIcon } from "@repo/contents/_lib/curriculum/material"; import { Badge } from "@repo/design-system/components/ui/badge"; import { GradientBlock } from "@repo/design-system/components/ui/gradient-block"; import { HugeIcons } from "@repo/design-system/components/ui/huge-icons"; import NavigationLink from "@repo/design-system/components/ui/navigation-link"; -import { cleanSlug } from "@repo/utilities/helper"; import { useLocale, useTranslations } from "next-intl"; -import { useState } from "react"; /** Renders the home-screen trending learning objects for the current locale. */ export function HomeTrending() { const t = useTranslations("Home"); const locale = useLocale(); - const [timeRange] = useState(() => getTrendingTimeRange(7, Date.now())); - const { data, isPending } = useQueryWithStatus( api.curriculumLessons.queries.getTrendingSubjects, { locale, - since: timeRange.since, - until: timeRange.until, + windowKey: "7d", } ); @@ -47,8 +41,8 @@ export function HomeTrending() { {data.map((subject) => (
diff --git a/apps/www/components/tracking/tracker.tsx b/apps/www/components/tracking/tracker.tsx index 2987707324..460870be10 100644 --- a/apps/www/components/tracking/tracker.tsx +++ b/apps/www/components/tracking/tracker.tsx @@ -1,25 +1,34 @@ "use client"; +import type { LearningContextInput } from "@repo/backend/convex/contents/context"; import type { Locale } from "@repo/backend/convex/lib/validators/contents"; import type { PropsWithChildren } from "react"; import { useRecordContentView } from "@/lib/hooks/use-record-content-view"; /** Graph content-view tracking inputs for a rendered learning page. */ interface Props { - contentId: string; + contentId?: string | null; + context?: LearningContextInput; delay?: number; locale: Locale; } -/** Records a delayed graph content view while rendering children unchanged. */ +/** + * Records a delayed graph content view when a runtime content identity exists. + * + * Rendering remains children-first so route modules can compose one explicit + * page tree while this tracking seam becomes inert for untracked content. + */ export function ContentViewTracker({ contentId, + context, locale, children, delay = 3000, }: PropsWithChildren) { useRecordContentView({ contentId, + context, locale, delay, }); diff --git a/apps/www/lib/hooks/use-record-content-view.ts b/apps/www/lib/hooks/use-record-content-view.ts index 5bc6ffa16f..face0e27fc 100644 --- a/apps/www/lib/hooks/use-record-content-view.ts +++ b/apps/www/lib/hooks/use-record-content-view.ts @@ -3,22 +3,26 @@ import { useDocumentVisibility, useLocalStorage } from "@mantine/hooks"; import { captureException } from "@repo/analytics/posthog"; import { api } from "@repo/backend/convex/_generated/api"; +import type { LearningContextInput } from "@repo/backend/convex/contents/context"; import type { Locale } from "@repo/backend/convex/lib/validators/contents"; import { generateNanoId } from "@repo/design-system/lib/utils"; -import { useMutation } from "convex/react"; +import { useConvexAuth, useMutation } from "convex/react"; import { Effect } from "effect"; import { useEffect, useState } from "react"; import { useContentViews } from "@/lib/context/use-content-views"; +import { useUser } from "@/lib/context/use-user"; +import { createContentViewKey } from "@/lib/hooks/views"; /** Client-side graph content-view recording configuration. */ interface UseRecordContentViewOptions { - contentId: string; + contentId?: string | null; + context?: LearningContextInput; delay?: number; locale: Locale; } /** - * Records unique content views per user/device. + * Records unique content views per user/device when a content identity exists. * * Design: Backend tracks first and last view timestamps. * Local deduplication prevents rapid duplicate calls within session. @@ -28,6 +32,7 @@ interface UseRecordContentViewOptions { */ export function useRecordContentView({ contentId, + context, locale, delay = 3000, }: UseRecordContentViewOptions) { @@ -37,10 +42,21 @@ export function useRecordContentView({ const markAsViewed = useContentViews((s) => s.markAsViewed); const isViewed = useContentViews((s) => s.isViewed); + const { isAuthenticated, isLoading } = useConvexAuth(); + const { isUserPending, signedInUserId } = useUser((state) => ({ + isUserPending: state.isPending, + signedInUserId: state.user?.appUser._id ?? null, + })); const documentState = useDocumentVisibility(); const isVisible = documentState === "visible"; - const viewKey = `${locale}:${contentId}`; + const viewKey = createContentViewKey({ + authenticated: isAuthenticated, + locale, + contentId, + context, + signedInUserId, + }); const [defaultDeviceId] = useState( () => `${Date.now()}-${generateNanoId(9)}` ); @@ -50,6 +66,18 @@ export function useRecordContentView({ }); useEffect(() => { + if (!contentId) { + return; + } + + if (isLoading || isUserPending) { + return; + } + + if (isAuthenticated && !signedInUserId) { + return; + } + if (isViewed(viewKey)) { return; } @@ -64,6 +92,7 @@ export function useRecordContentView({ try: () => recordView({ contentId, + ...(context ? { context } : {}), locale, deviceId, }), @@ -74,6 +103,7 @@ export function useRecordContentView({ Effect.sync(() => captureException(error, { contentId, + contextMode: context?.mode ?? "canonical", locale, source: "record-content-view", }) @@ -88,13 +118,18 @@ export function useRecordContentView({ }; }, [ contentId, + context, delay, deviceId, + isAuthenticated, + isLoading, isViewed, + isUserPending, isVisible, locale, markAsViewed, recordView, + signedInUserId, viewKey, ]); } diff --git a/apps/www/lib/hooks/views.test.ts b/apps/www/lib/hooks/views.test.ts new file mode 100644 index 0000000000..cc104b4272 --- /dev/null +++ b/apps/www/lib/hooks/views.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { createContentViewKey } from "@/lib/hooks/views"; + +describe("createContentViewKey", () => { + it("keeps anonymous and signed-in view attempts separate", () => { + const input = { + contentId: "asset:id:material:mathematics:algebra:linear", + locale: "id", + } as const; + + expect( + createContentViewKey({ ...input, authenticated: false }) + ).not.toEqual(createContentViewKey({ ...input, authenticated: true })); + }); + + it("keeps signed-in learners in separate dedupe buckets", () => { + const input = { + authenticated: true, + contentId: "asset:id:material:mathematics:algebra:linear", + locale: "id", + } as const; + + expect( + createContentViewKey({ ...input, signedInUserId: "user-first" }) + ).not.toEqual( + createContentViewKey({ ...input, signedInUserId: "user-second" }) + ); + }); + + it("preserves verified placement context in the dedupe key", () => { + expect( + createContentViewKey({ + authenticated: true, + contentId: "asset:id:material:mathematics:algebra:linear", + context: { + mode: "placement", + nodeKey: "node:linear", + programKey: "snbt-2026", + }, + locale: "id", + signedInUserId: "user-1", + }) + ).toBe( + "user:user-1:id:asset:id:material:mathematics:algebra:linear:placement:snbt-2026:node:linear" + ); + }); + + it("keeps untracked content attempts in a stable anonymous bucket", () => { + expect( + createContentViewKey({ + authenticated: false, + contentId: null, + locale: "id", + }) + ).toBe("anonymous:id:untracked:canonical::"); + }); +}); diff --git a/apps/www/lib/hooks/views.ts b/apps/www/lib/hooks/views.ts new file mode 100644 index 0000000000..4f33c29216 --- /dev/null +++ b/apps/www/lib/hooks/views.ts @@ -0,0 +1,30 @@ +import type { LearningContextInput } from "@repo/backend/convex/contents/context"; +import type { Locale } from "@repo/backend/convex/lib/validators/contents"; + +/** Builds the local dedupe key for one engaged content-view attempt. */ +export function createContentViewKey({ + authenticated, + contentId, + context, + locale, + signedInUserId, +}: { + readonly authenticated: boolean; + readonly contentId?: string | null; + readonly context?: LearningContextInput; + readonly locale: Locale; + readonly signedInUserId?: string | null; +}) { + const viewerKey = authenticated + ? `user:${signedInUserId ?? "pending"}` + : "anonymous"; + + return [ + viewerKey, + locale, + contentId ?? "untracked", + context?.mode ?? "canonical", + context?.programKey ?? "", + context?.nodeKey ?? "", + ].join(":"); +} diff --git a/apps/www/lib/routing/locale/resolve.test.ts b/apps/www/lib/routing/locale/resolve.test.ts index 85fcffb44e..7307afd0d4 100644 --- a/apps/www/lib/routing/locale/resolve.test.ts +++ b/apps/www/lib/routing/locale/resolve.test.ts @@ -5,6 +5,17 @@ import { Effect } from "effect"; import { afterEach, describe, expect, it, vi } from "vitest"; import { resolveLocalizedNavigationHref } from "@/lib/routing/locale/resolve"; +/** + * Keeps contextual hrefs unchanged in tests that isolate locale projection + * failures instead of material context validation. + */ +function preserveContextualHref( + input: Parameters[0] +) { + return input.href; +} + +/** Resolves a localized href through the Effect boundary used by route callers. */ function resolveHref(href: string, locale: "en" | "id") { return Effect.runSync(resolveLocalizedNavigationHref({ href, locale })); } @@ -160,6 +171,7 @@ describe("resolveLocalizedNavigationHref", () => { projectRouteToLocale: () => undefined, resolveMaterialHeaderLink: () => undefined, resolveRouteByPath: () => idOnlyRoute, + toContextualMaterialHref: preserveContextualHref, }; vi.spyOn( diff --git a/apps/www/lib/sitemap/routes.test.ts b/apps/www/lib/sitemap/routes.test.ts index e20be213f1..9076e5cc4a 100644 --- a/apps/www/lib/sitemap/routes.test.ts +++ b/apps/www/lib/sitemap/routes.test.ts @@ -386,8 +386,11 @@ const publicRouteRows: RuntimeSitemapPublicRoute[] = [ displayGroupTitle: undefined, iconKey: "mathematics", kind: "curriculum-context", + level: undefined, locale: "en", materialDomain: "mathematics", + materialCardDescription: undefined, + materialCardTitle: undefined, materialKey: "lesson.mathematics.integral", nodeKey: "merdeka.class-10.mathematics", order: 1, @@ -407,8 +410,11 @@ const publicRouteRows: RuntimeSitemapPublicRoute[] = [ displayGroupTitle: undefined, iconKey: undefined, kind: "subject-lesson", + level: undefined, locale: "en", materialDomain: "mathematics", + materialCardDescription: undefined, + materialCardTitle: undefined, materialKey: "lesson.mathematics.integral", nodeKey: undefined, order: 1, diff --git a/apps/www/lib/utils/__tests__/browser.test.ts b/apps/www/lib/utils/__tests__/browser.test.ts index 10f3713c68..145e467105 100644 --- a/apps/www/lib/utils/__tests__/browser.test.ts +++ b/apps/www/lib/utils/__tests__/browser.test.ts @@ -1,6 +1,10 @@ // @vitest-environment node import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { getLocale, getPathname } from "@/lib/utils/browser"; +import { + getLocale, + getMaterialContextHint, + getPathname, +} from "@/lib/utils/browser"; describe("getLocale", () => { beforeEach(() => { @@ -100,3 +104,27 @@ describe("getPathname", () => { expect(getPathname()).toBe("/blog/2024/12/24/release-notes"); }); }); + +describe("getMaterialContextHint", () => { + beforeEach(() => { + vi.stubGlobal("window", { + location: { search: "" }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns the raw material context hint from the query string", () => { + window.location.search = "?ctx=program.node"; + + expect(getMaterialContextHint()).toBe("program.node"); + }); + + it("returns undefined when the page has no material context hint", () => { + window.location.search = "?other=value"; + + expect(getMaterialContextHint()).toBeUndefined(); + }); +}); diff --git a/apps/www/lib/utils/browser.ts b/apps/www/lib/utils/browser.ts index 8e35c2b89a..ce619170fc 100644 --- a/apps/www/lib/utils/browser.ts +++ b/apps/www/lib/utils/browser.ts @@ -1,3 +1,4 @@ +import { MATERIAL_CONTEXT_QUERY_PARAM } from "@repo/contents/_types/route/material/context"; import { routing } from "@repo/internationalization/src/routing"; import { hasLocale } from "next-intl"; @@ -39,3 +40,12 @@ export function getPathname() { // If no locale in path, return the full pathname return pathname || "/"; } + +/** Reads the raw material context hint for server-side Nina validation. */ +export function getMaterialContextHint() { + return ( + new URLSearchParams(window.location.search).get( + MATERIAL_CONTEXT_QUERY_PARAM + ) ?? undefined + ); +} diff --git a/docs/adr/0001-nina-engagement-architecture.md b/docs/adr/0001-nina-engagement-architecture.md new file mode 100644 index 0000000000..41a8165b19 --- /dev/null +++ b/docs/adr/0001-nina-engagement-architecture.md @@ -0,0 +1,48 @@ +# ADR 0001: Single NinaHarness with Internal LearningCapabilities and Production Evals + +## Status + +Accepted for PR #187. + +## Context + +Nina needs durable learning context, AI SDK streaming, deterministic education +tools, bounded operational traces, and production evals without exposing AI SDK +callback shapes through app routes. Learning engagement also needs Continue +Learning and popularity reads that stay bounded as raw view volume grows. + +## Decision + +Nakafa uses one package-owned `NinaHarness` Effect service with a single external +`stream` Interface. The Next route remains the HTTP/auth boundary and binds +app-owned adapters for Convex persistence, diagnostics, and Nakafa content/search +services. + +ToolLoopAgent, `prepareStep`, repair, writer callbacks, model selection, +LearningCapability policy, evidence envelopes, trace summaries, and AI SDK UI +stream response composition stay internal to `packages/ai/nina`. + +Math, Nakafa, and research are internal LearningCapabilities. They return +schema-derived evidence instead of owning public app contracts. Math is +deterministic-first; Nakafa owns Nakafa content evidence; research is reserved for +source-heavy, current, or external work. + +Production evals use schema-derived EvalCases, EvalSuites, and EvalRuns over +NinaHarness and LearningCapability seams. Provider-backed evals are opt-in for +provider behavior changes; deterministic suites are the default readiness gate. + +Learning engagement uses durable read models and counters for product reads. Raw +learning view events are bounded audit data after integrity proof. Lifetime +popularity is stored in durable counters/checkpoints, with Aggregate reserved for +ranked read indexes where it simplifies bounded top-N reads. + +## Consequences + +- Routes do not own AI SDK callback, tool-loop, or capability contracts. +- Product contracts derive from Effect Schema, generated Convex types, AI SDK + interop types, or Effect services instead of duplicated TypeScript shapes. +- LearningCapability traces are bounded operational data, not canonical chat + transcripts. +- App adapters can change deployment details without changing NinaHarness. +- Popularity cleanup cannot happen until the Integrity Module proves raw + coverage, checkpoint progress, lifetime inclusion, and rank-index consistency. diff --git a/packages/ai/agents/docs/architecture.md b/packages/ai/agents/docs/architecture.md index d4b8c04f37..2610022cb1 100644 --- a/packages/ai/agents/docs/architecture.md +++ b/packages/ai/agents/docs/architecture.md @@ -1,36 +1,39 @@ # Nina Agent Architecture -Nina has one chat route, one orchestrator, and three specialist agents. Nakafa -content reading is owned by `packages/contents`; Nakafa discovery is served by -the Convex `contentSearch` read model written during content sync. +Nina has one chat route and one external package Interface: +`NinaHarness.stream`. Math, Nakafa, and research are internal +LearningCapabilities used by the harness to collect bounded evidence before the +final answer. ## Flow ```mermaid flowchart LR User["User asks Nina"] --> Route["apps/www /api/chat"] - Route --> Check{"Verified page missing from retained chat?"} - Check -- "yes" --> Forced["prepareStep: force nakafa once"] + Route --> Harness["NinaHarness.stream"] + Harness --> Check{"Verified page evidence needed?"} + Check -- "yes" --> Forced["prepareStep: force Nakafa once"] Check -- "no" --> Source{"Latest user text names an external source?"} Source -- "yes" --> ResearchForced["prepareStep: force research once"] - Source -- "no" --> Normal["normal tool choice"] - Forced --> Orchestrator["orchestrator"] - ResearchForced --> Orchestrator - Normal --> Orchestrator - Orchestrator --> Nakafa["nakafa agent"] - Orchestrator --> Research["research agent"] - Orchestrator --> Math["math agent"] + Source -- "no" --> Normal["normal ToolLoopAgent choice"] + Forced --> Capabilities["internal LearningCapabilities"] + ResearchForced --> Capabilities + Normal --> Capabilities + Capabilities --> Nakafa["Nakafa capability"] + Capabilities --> Research["research capability"] + Capabilities --> Math["math capability"] Nakafa --> Search["Convex full-text search"] Nakafa --> Content["contents Effect service"] Mcp["apps/mcp"] --> Search Mcp --> Content - Research --> ResearchEvidence["evidence phase: Firecrawl then Google grounding / scrape"] - ResearchEvidence --> ResearchSynthesis["synthesis phase: Output.object"] - Math --> Evidence["evaluate / simplify / differentiate / compare"] + Research --> ResearchEvidence["source evidence phase"] + ResearchEvidence --> ResearchSynthesis["structured synthesis"] + Math --> MathEvidence["deterministic evidence"] Nakafa --> NakafaEvidence["content IDs and retrieved content"] - ResearchSynthesis --> ResearchCitations["external citation links"] - NakafaEvidence --> Final["final answer evidence contract"] - ResearchCitations --> Final + ResearchSynthesis --> Envelope["EvidenceEnvelope"] + MathEvidence --> Envelope + NakafaEvidence --> Envelope + Envelope --> Final["Nina answer synthesis"] Final --> Inline["research links inline, Nakafa source chips separate"] ``` @@ -48,24 +51,24 @@ doc. Prefer diagrams over long prose so each file stays skimmable. ```mermaid sequenceDiagram participant Route as "/api/chat" - participant Main as "orchestrator" - participant Tool as "nakafa" + participant Harness as "NinaHarness" + participant Nakafa as "Nakafa capability" participant Content as "contents service" participant Convex as "chat parts" Route->>Convex: hydrate retained messages - Route->>Route: check data-nakafa content for current URL + Route->>Harness: stream turn with validated context + Harness->>Harness: check data-nakafa content for current URL alt verified and missing - Route->>Main: force nakafa on step 0 - Main->>Tool: one tool call - Tool->>Content: read current content_ref directly - Tool-->>Convex: persist data-nakafa kind content + Harness->>Nakafa: force one current-page read on step 0 + Nakafa->>Content: read current content_ref directly + Nakafa-->>Convex: persist data-nakafa kind content else unverified or already fetched - Route->>Route: check latest user text for external source reference + Harness->>Harness: check latest user text for external source reference alt explicit external source - Route->>Main: force research on step 0 + Harness->>Harness: force research on step 0 else no explicit external source - Route->>Main: normal tool choice + Harness->>Harness: normal ToolLoopAgent choice end end ``` @@ -81,19 +84,20 @@ flowchart LR ReadRef --> Read["read tool"] Exercise --> StepResults["AI SDK step toolResults"] Read --> StepResults - StepResults --> Evidence["Nakafa agent evidence text"] - Evidence --> Orchestrator["Nina orchestrator synthesis"] + StepResults --> Evidence["Nakafa capability evidence text"] + Evidence --> Envelope["EvidenceEnvelope"] + Envelope --> Final["Nina answer synthesis"] ``` Search can return question-level exercise hits, but the handoff to the exercise tool uses the parent set reference. A specific question number remains structured -tool input (`exercise_number`) instead of local prompt parsing. The Nakafa agent -returns collected AI SDK tool-result evidence, so user-facing prose is composed -by Nina from retrieved content rather than invented inside the retrieval agent. +tool input (`exercise_number`) instead of local prompt parsing. The Nakafa +capability returns collected AI SDK tool-result evidence, so user-facing prose is +composed by Nina from retrieved content rather than invented inside retrieval. Exercise discovery uses the model-provided search input directly before -`selectExerciseRef` chooses a parent set. The orchestrator tool query is the -single Nakafa subagent task; there is no second raw-request planning layer that -can override or contaminate the search query. +`selectExerciseRef` chooses a parent set. The Nakafa capability task is the +single Nakafa evidence request; there is no second raw-request planning layer +that can override or contaminate the search query. ## Contracts @@ -122,8 +126,8 @@ can override or contaminate the search query. and `activeTools`. - Nakafa exercise search normalizes question-level refs to parent set refs before the exercise tool runs. -- Nakafa subagent output is derived from AI SDK step `toolResults`, not from - retrieval-agent free-form prose. +- Nakafa capability output is derived from AI SDK step `toolResults`, not from + retrieval free-form prose. - Explicit external source references are extracted by `@repo/ai/lib/source`. Routing uses them to enter research, and research receives the full ordered source list for exact-source reading. diff --git a/packages/ai/agents/docs/research-citations.md b/packages/ai/agents/docs/research-citations.md index 2fa5adb10f..b26b40fbd1 100644 --- a/packages/ai/agents/docs/research-citations.md +++ b/packages/ai/agents/docs/research-citations.md @@ -17,8 +17,8 @@ Research evidence has two separate surfaces: ```mermaid flowchart TD - User["User asks for source-backed answer"] --> Orchestrator["Nina orchestrator"] - Orchestrator --> Research["deepResearch tool"] + User["User asks for source-backed answer"] --> Harness["NinaHarness"] + Harness --> Research["deepResearch capability"] Research --> Evidence["Evidence phase (text mode)"] Evidence --> Firecrawl["webSearch / Firecrawl"] Firecrawl --> SearchUI["query-scoped source rows"] @@ -34,8 +34,8 @@ flowchart TD Retry -->|no| Synthesis Retry -->|yes| Render["formatResearchOutput()"] Render --> Linked["Research markdown with inline [source](url) links"] - Linked --> Orchestrator - Orchestrator --> Final["Final answer"] + Linked --> Harness + Harness --> Final["Final answer"] Final --> Response["Response markdown renderer"] Response --> Anchor["markdown Anchor"] Anchor --> Source["Source chip with favicon"] diff --git a/packages/ai/agents/math/prompt.ts b/packages/ai/agents/math/prompt.ts index c1b0628729..f1968fbb16 100644 --- a/packages/ai/agents/math/prompt.ts +++ b/packages/ai/agents/math/prompt.ts @@ -2,13 +2,14 @@ import { createPrompt } from "@repo/ai/prompt/utils"; import type { AgentContext } from "@repo/ai/types/agents"; import type { Locale } from "@repo/utilities/locales"; -interface MathPromptProps { - context: AgentContext; - locale: Locale; -} - /** Builds the system prompt for Nina's deterministic math agent. */ -export function mathPrompt({ locale, context }: MathPromptProps) { +export function mathPrompt({ + locale, + context, +}: { + readonly context: AgentContext; + readonly locale: Locale; +}) { return createPrompt({ taskContext: ` # Identity diff --git a/packages/ai/agents/math/repair.ts b/packages/ai/agents/math/repair.ts index b8667ad935..5d55b7e6e9 100644 --- a/packages/ai/agents/math/repair.ts +++ b/packages/ai/agents/math/repair.ts @@ -17,11 +17,6 @@ import { Effect, Option, Schema } from "effect"; type MathRepairOptions = Parameters>[0]; -interface RepairMathToolCallParams extends MathRepairOptions { - modelId: ModelId; - task: string; -} - const repairArgumentsSchema = Schema.Record({ key: Schema.String, value: Schema.Unknown, @@ -52,7 +47,10 @@ export const repairMathToolCall = Effect.fn("math.repairToolCall")(function* ({ task, toolCall, tools, -}: RepairMathToolCallParams) { +}: MathRepairOptions & { + readonly modelId: ModelId; + readonly task: string; +}) { if (NoSuchToolError.isInstance(error)) { return null; } diff --git a/packages/ai/agents/math/tools/compute.ts b/packages/ai/agents/math/tools/compute.ts index b44c2ba190..1826b03b92 100644 --- a/packages/ai/agents/math/tools/compute.ts +++ b/packages/ai/agents/math/tools/compute.ts @@ -72,18 +72,16 @@ function decodeRecoveryMessage(message: string) { return "Ask the user for the exact missing expression or data in their language."; } -interface ComputeParams { - input: unknown; - toolCallId: string; - writer: UIMessageStreamWriter; -} - /** Runs one deterministic math request and writes the math evidence data part. */ export const compute = Effect.fn("math.compute")(function* ({ input, toolCallId, writer, -}: ComputeParams) { +}: { + readonly input: unknown; + readonly toolCallId: string; + readonly writer: UIMessageStreamWriter; +}) { const decoded = yield* Schema.decodeUnknown(MathToolInputSchema)(input).pipe( Effect.either ); diff --git a/packages/ai/agents/nakafa/prompt.test.ts b/packages/ai/agents/nakafa/prompt.test.ts index 820259d725..fcba0d99ab 100644 --- a/packages/ai/agents/nakafa/prompt.test.ts +++ b/packages/ai/agents/nakafa/prompt.test.ts @@ -1,4 +1,5 @@ import { nakafaAgentPrompt } from "@repo/ai/agents/nakafa/prompt"; +import { LearningProgramKeySchema } from "@repo/contents/_types/program/schema"; import { describe, expect, it } from "vitest"; const context = { @@ -99,7 +100,7 @@ describe("nakafaAgentPrompt", () => { ], program: { coverageStatus: "partial", - key: "merdeka", + key: LearningProgramKeySchema.make("merdeka"), kind: "school-curriculum", title: "Kurikulum Merdeka", versionLabel: "Indonesia", diff --git a/packages/ai/agents/nakafa/prompt.ts b/packages/ai/agents/nakafa/prompt.ts index 26a856fb1e..5e3664f301 100644 --- a/packages/ai/agents/nakafa/prompt.ts +++ b/packages/ai/agents/nakafa/prompt.ts @@ -3,13 +3,14 @@ import { createPrompt } from "@repo/ai/prompt/utils"; import type { AgentContext } from "@repo/ai/types/agents"; import type { Locale } from "@repo/utilities/locales"; -interface Props { - context: AgentContext; - locale: Locale; -} - /** Builds the system prompt for the Nakafa content agent. */ -export function nakafaAgentPrompt({ locale, context }: Props) { +export function nakafaAgentPrompt({ + locale, + context, +}: { + readonly context: AgentContext; + readonly locale: Locale; +}) { return createPrompt({ taskContext: ` # Identity diff --git a/packages/ai/agents/nakafa/step.ts b/packages/ai/agents/nakafa/step.ts index 35ae3475c8..32d14409e6 100644 --- a/packages/ai/agents/nakafa/step.ts +++ b/packages/ai/agents/nakafa/step.ts @@ -9,13 +9,6 @@ import { readPracticeSourceRouteByPath } from "@repo/contents/_types/route/pract import type { ModelMessage } from "ai"; import { Option } from "effect"; -/** Minimal AI SDK tool-call shape needed to detect a specific Nakafa tool step. */ -interface ToolStep { - toolCalls: readonly { - toolName: ToolName; - }[]; -} - /** * Selects the graph exercise reference to read after an exercise-scoped search. * Set-level search rows are preferred when present; otherwise the first @@ -197,7 +190,9 @@ export function prepareReadStep( */ export function prepareTaxonomyAnswerStep( messages: ModelMessage[], - steps: readonly ToolStep[] + steps: readonly { + readonly toolCalls: readonly { readonly toolName: ToolName }[]; + }[] ) { const hasTaxonomyToolCall = steps.some((step) => step.toolCalls.some((toolCall) => toolCall.toolName === "taxonomy") @@ -254,7 +249,9 @@ export function prepareTaxonomyAnswerStep( * searching instead of spending the remaining loop budget on repeated discovery. */ export function shouldAnswerFromNakafaEvidence( - steps: readonly ToolStep[] + steps: readonly { + readonly toolCalls: readonly { readonly toolName: ToolName }[]; + }[] ) { const searchCalls = steps.flatMap((step) => step.toolCalls.filter((toolCall) => toolCall.toolName === "search") @@ -269,7 +266,12 @@ export function shouldAnswerFromNakafaEvidence( */ export function prepareAnswerFromNakafaEvidenceStep< const ToolName extends string, ->(messages: ModelMessage[], steps: readonly ToolStep[]) { +>( + messages: ModelMessage[], + steps: readonly { + readonly toolCalls: readonly { readonly toolName: ToolName }[]; + }[] +) { if (!shouldAnswerFromNakafaEvidence(steps)) { return; } diff --git a/packages/ai/agents/nakafa/tools/exercise.ts b/packages/ai/agents/nakafa/tools/exercise.ts index e4a910c895..af6a6305ea 100644 --- a/packages/ai/agents/nakafa/tools/exercise.ts +++ b/packages/ai/agents/nakafa/tools/exercise.ts @@ -8,12 +8,6 @@ import { Effect, Either, Option } from "effect"; type Writer = Pick, "write">; -interface Params { - input: NakafaAgentExerciseOptions; - toolCallId: string; - writer: Writer; -} - const notFoundMessage = "Nakafa exercise content was not found."; /** Reads a Nakafa exercise set or single exercise and writes a preview UI part. */ @@ -21,7 +15,11 @@ export const exercise = Effect.fn("nakafa.exercise")(function* ({ input, toolCallId, writer, -}: Params) { +}: { + readonly input: NakafaAgentExerciseOptions; + readonly toolCallId: string; + readonly writer: Writer; +}) { yield* Effect.sync(() => writer.write({ id: toolCallId, diff --git a/packages/ai/agents/nakafa/tools/quran.ts b/packages/ai/agents/nakafa/tools/quran.ts index c4352be09d..56c2ca5f44 100644 --- a/packages/ai/agents/nakafa/tools/quran.ts +++ b/packages/ai/agents/nakafa/tools/quran.ts @@ -10,13 +10,6 @@ import { Effect, Either, Option } from "effect"; type Writer = Pick, "write">; -interface Params { - input: NakafaAgentQuranReferenceOptions; - locale: Locale; - toolCallId: string; - writer: Writer; -} - const invalidRangeMessage = "Invalid Quran verse range."; const notFoundMessage = "Nakafa Quran reference was not found."; const oversizedRangeMessage = "Quran reference range is too large."; @@ -27,7 +20,12 @@ export const quran = Effect.fn("nakafa.quran")(function* ({ locale, toolCallId, writer, -}: Params) { +}: { + readonly input: NakafaAgentQuranReferenceOptions; + readonly locale: Locale; + readonly toolCallId: string; + readonly writer: Writer; +}) { const dataInput = normalizeQuranInput(input, locale); yield* Effect.sync(() => diff --git a/packages/ai/agents/nakafa/tools/read.ts b/packages/ai/agents/nakafa/tools/read.ts index f2152426a3..5bbdb8d264 100644 --- a/packages/ai/agents/nakafa/tools/read.ts +++ b/packages/ai/agents/nakafa/tools/read.ts @@ -8,12 +8,6 @@ import { Effect, Either, Option } from "effect"; type Writer = Pick, "write">; -interface Params { - input: NakafaAgentReadOptions; - toolCallId: string; - writer: Writer; -} - const notFoundMessage = "Nakafa content was not found."; /** Reads one Nakafa content reference and writes a bounded preview UI part. */ @@ -21,7 +15,11 @@ export const read = Effect.fn("nakafa.read")(function* ({ input, toolCallId, writer, -}: Params) { +}: { + readonly input: NakafaAgentReadOptions; + readonly toolCallId: string; + readonly writer: Writer; +}) { yield* Effect.sync(() => writer.write({ id: toolCallId, diff --git a/packages/ai/agents/nakafa/tools/search.ts b/packages/ai/agents/nakafa/tools/search.ts index fd2111db21..04320d59f6 100644 --- a/packages/ai/agents/nakafa/tools/search.ts +++ b/packages/ai/agents/nakafa/tools/search.ts @@ -16,20 +16,18 @@ type SearchInput = ReturnType; const searchTokenPattern = /[\p{L}\p{N}]+/gu; const routeSeparatorPattern = /[/_-]+/gu; -interface Params { - input: NakafaAgentSearchInput; - locale: Locale; - toolCallId: string; - writer: Writer; -} - /** Searches Nakafa content and writes a bounded `data-nakafa` UI part. */ export const search = Effect.fn("nakafa.search")(function* ({ input, locale, toolCallId, writer, -}: Params) { +}: { + readonly input: NakafaAgentSearchInput; + readonly locale: Locale; + readonly toolCallId: string; + readonly writer: Writer; +}) { const dataInput = getSearchInput(input, locale); const searchInputs = getSearchInputs(dataInput); const queryTokens = getSearchTokens(dataInput.queries ?? []); diff --git a/packages/ai/agents/nakafa/tools/taxonomy.ts b/packages/ai/agents/nakafa/tools/taxonomy.ts index a0cfe4f8d6..35626cddc2 100644 --- a/packages/ai/agents/nakafa/tools/taxonomy.ts +++ b/packages/ai/agents/nakafa/tools/taxonomy.ts @@ -9,20 +9,18 @@ import { Effect } from "effect"; type Writer = Pick, "write">; -interface Params { - input: NakafaAgentTaxonomyOptions; - locale: Locale; - toolCallId: string; - writer: Writer; -} - /** Reads Nakafa taxonomy and writes a bounded preview UI part. */ export const taxonomy = Effect.fn("nakafa.taxonomy")(function* ({ input, locale, toolCallId, writer, -}: Params) { +}: { + readonly input: NakafaAgentTaxonomyOptions; + readonly locale: Locale; + readonly toolCallId: string; + readonly writer: Writer; +}) { const dataInput = { ...input, locale }; yield* Effect.sync(() => diff --git a/packages/ai/agents/orchestrator/names.ts b/packages/ai/agents/orchestrator/names.ts deleted file mode 100644 index e66e0c88d2..0000000000 --- a/packages/ai/agents/orchestrator/names.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { ToolName } from "@repo/ai/schema/tools"; - -/** - * Tool names used by the orchestrator. - */ -export const TOOL_NAMES = { - nakafa: "nakafa", - deepResearch: "deepResearch", - math: "math", -} satisfies Record; diff --git a/packages/ai/agents/orchestrator/prompt.ts b/packages/ai/agents/orchestrator/prompt.ts deleted file mode 100644 index 53407bc1cd..0000000000 --- a/packages/ai/agents/orchestrator/prompt.ts +++ /dev/null @@ -1,401 +0,0 @@ -import { formatLearningProfilePromptContext } from "@repo/ai/prompt/learning-profile"; -import { createPrompt } from "@repo/ai/prompt/utils"; -import type { AgentLearningProfile } from "@repo/ai/types/agents"; -import type { PromptUserRole } from "@repo/ai/types/roles"; -import type { Locale } from "@repo/utilities/locales"; -import dedent from "dedent"; - -interface SystemPromptProps { - /** The current date. */ - currentDate: string; - /** Current page information including verification status. */ - currentPage: { - /** The locale of the current page. */ - locale: Locale; - /** The slug of the current page. */ - slug: string; - /** Whether the slug has been verified to exist in the content system. */ - verified: boolean; - }; - /** The active learning profile and first plan items selected by the user. */ - learningProfile?: AgentLearningProfile; - /** The URL of the current page. */ - url: string; - /** The current time. */ - userLocation: { - /** The city of the user's location. */ - city: string; - /** The country of the user's location. */ - country: string; - /** The latitude of the user's location. */ - latitude: string; - /** The longitude of the user's location. */ - longitude: string; - /** The country region of the user's location. */ - countryRegion: string; - }; - /** The role of the user. */ - userRole?: PromptUserRole; -} - -/** Builds Nina's orchestrator prompt with routing rules for specialist agents. */ -export function nakafaPrompt({ - url, - currentPage, - currentDate, - learningProfile, - userLocation, - userRole, -}: SystemPromptProps) { - return createPrompt({ - taskContext: ` - # Identity - - Your name is Nina. - You are Nakafa's education assistant for K-12 through university learners. - If prior context makes the user's goal clear, proactively take the necessary action. - Be friendly, direct, source-grounded, concise, and age-appropriate. - Stay in character as a real human assistant. - - ${getUserRoleContext(userRole)} - `, - - toneContext: ` - # Communication Style - - Be casual, warm, concise, and clear. - Use simple words, everyday analogies, short sentences, and small steps. - Correct mistakes clearly without shaming the user. - Always use the user's language. Never mix languages. - Use emojis only when they genuinely help the tone. - `, - - backgroundData: ` - # Runtime Context - - Current page: - - url: ${url} - - locale: ${currentPage.locale} - - slug: ${currentPage.slug} - - verified: ${currentPage.verified ? "yes" : "no"} - - User context: - - date: ${currentDate} - - city: ${userLocation.city} - - country: ${userLocation.country} - - country region: ${userLocation.countryRegion} - - latitude: ${userLocation.latitude} - - longitude: ${userLocation.longitude} - - ${formatLearningProfilePromptContext(learningProfile)} - `, - - toolUsageGuidelines: ` - # Tool Usage Guidelines - - Use the smallest reliable evidence path before the final answer: - - Nakafa: Nakafa-owned lessons, articles, Quran references, and exercises. - - deepResearch: external, current, official, or source-backed claims. - - math: deterministic calculations, formulas, answer keys, and verification. - - Answer directly only for low-risk requests that need no source, current fact, or math check: - - greetings. - - preferences. - - simple rewrites. - - ## Specialist Input Contract - - All specialist tools share compact fields: - - request: task-relevant user details only. - - objective: the specialist job only. - - requirements: real retrieval or verification constraints only; omit when none exist. - - request must: - - keep connective wording in the user's language. - - preserve technical names and terms exactly. - - when the user writes in a non-English language, keep the cleaned request in that same language. - - preserve names, dates, URLs, domains, versions, source owners, formulas, values, variables, matrices, data, level, context, and requested deliverables. - - omit unrelated text, repetition, emotional phrasing, and tool or prompt noise. - - avoid copying the full user message when only part is relevant. - - Specialist-specific fields: - - deepResearch.sourceRequirements: source ownership, recency, domain, URL, and credibility. - - nakafa.deliverables: lessons, summaries, examples, exercises, answers, Quran references, or article needs. - - math.given: user-provided expressions, equations, variables, assumptions, matrices, data, selected exercise content, or answer keys. - - Tool inputs must not include persona rules, global formatting rules, fallback answer wording, or outcome-dependent instructions. - - ## Routing Standard - - Decide from the user's request and gathered evidence, not from content slugs, material names, section labels, or UI labels alone. - Every factual claim needs the right evidence: - - Nakafa evidence for Nakafa-owned content. - - Source-backed research evidence for external or current claims. - - Math evidence for calculations, formulas, numeric answers, answer keys, equivalence checks, probability, statistics, matrix properties, geometry, and discrete counting. - - If evidence is missing, call the matching specialist. - If evidence still cannot be gathered, answer with the limitation instead of guessing. - - ## Nakafa - - Use Nakafa first for named educational topics, lesson explanations, study requests, current verified page content, and educational practice. - Practice includes warmups, starter examples, hints, quick reviews, quizzes, tryout preparation, and preparation before practice. - - Nakafa routing rules: - - Preserve every requested deliverable. - - Include helpful retrieval context: URL, verified status, user goal, subject, grade, topic, article, exercise, or Quran context. - - Search first when the exact content reference is not known. - - Do not add a lesson or concept overview unless the user asks for one. - - For warmups or starter examples followed by practice, ask Nakafa for exercise evidence only; Nina can write a short setup from that evidence. - - If the user asks for explanation plus practice, include both needs. - - If the user only asks for practice, scope Nakafa to exercise retrieval and explanation. - - Do not use Nakafa to fill missing evidence for external, current, official-source, or source-owned verification questions. - Use Nakafa after weak external research only for a separate user-requested Nakafa deliverable: - - lessons. - - exercises. - - Quran content. - - articles. - - current verified page content. - - practice. - - ## deepResearch - - Use deepResearch before answering requests for: - - official documentation. - - source-backed claims. - - citations. - - external links. - - current or latest information. - - named products outside Nakafa. - This applies in every user language. Do not answer those requests from memory. - - Preserve source-scoping details in request or sourceRequirements: - - Products, APIs, libraries, features, versions, domains, URLs, source constraints, and document titles. - - Official, primary, maintainer, vendor, standards-body, paper-author, or named-domain requirements. - - Keep research inputs neutral when sources may be missing or weak: - - Ask for direct-source verification. - - Do not prewrite failed-verification wording. - - Do not tell deepResearch to say something was not found. - - ## math - - Use math for deterministic evidence across arithmetic, algebra, equations, inequalities, calculus, series, matrices, statistics, probability, geometry, and discrete math. - Use math to verify user-provided expressions, user-provided data, and math content retrieved from another evidence path. - - Do not use math as the first or only source for practice sets: warmups, quizzes, tryout preparation, examples, hints, or review tasks. - Call Nakafa first, then use math only for deterministic verification of selected content. - - Math input rules: - - Include the complete expression or data, target operation, variables, and learning goal when relevant. - - Put only user-provided or retrieved math facts in given; do not preload solution methods or derived formulas. - - For extrema, minimum, or maximum requests, ask math for the valid location and function value unless the user asks only for the input location. - - Preserve derivation, proof, and "why" deliverables so math returns the checked value plus the conceptual bridge. - - For multi-part requests, enumerate each calculation or verification. - - Do not collapse several computations into a vague objective such as "verify these calculations". - - If deterministic math is inconclusive, explain the limitation clearly. - - ## Combining Agents - - Use more than one specialist when the answer needs more than one evidence type. - Call independent specialists in parallel in the same step. - - For educational practice: - - Nakafa selects content. - - math verifies selected calculations. - - Never create practice content inside the math input. - - Include the exact example, exercise, answer key, and numeric claims that will appear in the final answer. - - Do not switch to different math content after verification. - - Use deepResearch for current, external, or source-backed information beyond Nakafa. - Use math after deepResearch when researched numbers or claims need calculation, comparison, statistics, or verification. - - Never invent source-specific content, current facts, exercise choices, citations, or verified math without relevant evidence. - After weak or missing deepResearch evidence for an external, current, official, or source-owned claim: - - Do not switch to generic Nakafa search just to provide something. - - Use another evidence path only when it satisfies the same source constraint or a separate user-requested learning/practice deliverable. - - Preserve source constraints in the final answer: - - Keep one product, domain, document, or official source scoped to the requested source. - - Do not add adjacent frameworks or generic alternatives unless the user asks for comparisons. - - If a specialist returns an error: - - Do not call the same specialist again with the same request. - - Use a different evidence path only when it can add new evidence. - - Otherwise answer with a clear limitation. - `, - - detailedTaskInstructions: ` - # Task Instructions - - Work in order: - 1. Understand the user's goal. - 2. Choose the smallest reliable evidence path. - 3. Use retrieved evidence before answering source-specific, current, or mathematical claims. - 4. Answer in the user's language with clear markdown. - - For external, current, official, or source-owned questions, source-backed research is the answer gate. - If research returns no source-backed finding: - - Use the research limitation as the answer for that verification part. - - Keep it as a process limitation, not a claim that sources, announcements, public information, or confirmations do not exist. - - Do not add greetings, advice, encouragement, unrelated Nakafa content, or extra bullets around a limitation-only answer. - - If the user also asks for study help or practice, separate that deliverable from the verification answer. - - Keep visible reasoning brief. Do not write long plans unless the user asks for one. - `, - - examples: ` - # Specialist Input Examples - - Good Nakafa input: - - request: cleaned user-language topic and practice scope. - - objective: find suitable Nakafa-owned exercise or lesson evidence. - - requirements: real content scope only. - - deliverables: requested Nakafa content pieces. - - Good math follow-up input: - - request: cleaned user-language math verification request. - - objective: check selected calculations, answer keys, and numeric claims. - - requirements: use only the selected evidence when verifying retrieved content. - - given: user-provided or retrieved expressions, data, assumptions, and answer keys. - - Good deepResearch input: - - request: cleaned user-language source-specific research request. - - objective: find direct source-backed evidence for the requested claim. - - sourceRequirements: named owners, domains, URLs, recency, or credibility constraints from the user. - - Bad specialist inputs: - - request: "Find something about math." Problem: vague and missing the specialist objective. - - request: "Use math because the page path has mathematics." Problem: routes from metadata instead of the actual request and evidence. - - request: translated user wording. Problem: loses the user's language and exact technical terms. - - objective: "Search everything and answer." Problem: mixes retrieval, verification, and final response. - - requirements: global output formatting rules. Problem: repeats answer-formatting instead of task constraints. - - requirements: scripted failed-outcome wording. Problem: scripts an outcome before evidence exists. - `, - - outputFormatting: ` - # Output Formatting Guidelines - - Use markdown only. Do not use HTML, XML, or other markup. - Never mention AI, tools, functions, prompts, or internal processes to users. - - ## Limitation-only research answers - - If research returns a single limitation sentence with no source-backed findings: - - Use that sentence as the full answer for the verification part. - - Do not paraphrase, decorate, or turn it into a search-result summary. - - Do not say information, evidence, proof, announcements, or sources were found or not found. - - ## Mathematical format - - Use LaTeX for numbers, variables, and expressions. - - Inline math: \\(...\\). - - Block math: \\[...\\]; use \\\\ for line breaks. - - Text inside math: use \\text{...}. - - Rewrite retrieved $...$ or $$...$$ math to \\(...\\) or \\[...\\]. - - Never use dollar delimiters or inline code for math. - - ## Code block format - - Use \`\`\`{language} for code blocks. - Add code comments only when necessary. - - Never use code blocks for mathematical content. - - Inline code: use \`...\`. - - Never use inline code for mathematical content. - - ## Diagrams - - Use \`\`\`mermaid title="..." description="..." for helpful flowcharts, graphs, and timelines. - The title and description are required, must match the response language, and must not repeat each other. - Inside Mermaid labels, use quoted Mermaid math syntax like "$$CO_2$$"; do not use Markdown math delimiters like \\(CO_2\\). - - ## Links - - Use concise descriptive [text](url) links. - When research results contain URLs, format them as [domain](url) links. - Cite external research sources inline in the exact sentence they support. - Use only links already present in external research evidence or current page context. - Preserve research markdown links for every claim that uses that evidence. - Preserve source-backed technical details exactly: - - Framework configuration. - - CLI commands. - - API names. - - Version numbers. - - Code shapes. - - Do not add product homepages, documentation links, parent objects, flags, wrappers, options, or source links from memory. - Each source-backed section or bullet must keep at least one supporting link. - Do not add Nakafa source labels, Nakafa domain links, or citation-style links for Nakafa-owned content. - Convert any research citation indexes into markdown links using the cited source URLs. - Never show numeric citation markers or append a source/reference/bibliography section. - - ## Lists - - Use short paragraphs for explanation and lists for clear distinctions. - Use 1., 2., 3. for ordered steps and - for unordered items. - Keep lists brief and indentation clean. - When a list item contains continuation text, block math, a diagram, or a code block, indent that child content under the list item instead of restarting at the page margin. - Multiple-choice options MUST be formatted as one markdown bullet per option: - - A. Option text - - B. Option text - - C. Option text - - D. Option text - - E. Option text - Never write multiple-choice options inline in one paragraph. - Never rely on raw line breaks without bullet markers for multiple-choice options. - - ## Headings - - Use ## (h2) or ### (h3) for headings. - Keep headings short and descriptive. - Never use # (h1), numbered headings, or decorative punctuation in headings. - `, - }); -} - -/** Builds user-role-specific behavior context without changing tool contracts. */ -function getUserRoleContext(userRole: SystemPromptProps["userRole"]) { - switch (userRole) { - case "teacher": - return dedent(` - **User is a teacher.** - - Support lesson planning, materials, assessment, pedagogy, classroom practice, and education research. - Be professional, efficient, and practical. - `); - - case "student": - return dedent(` - **User is a student.** - - Help the student understand, practice, and solve problems. - Use simple language, small steps, examples, and level-appropriate guidance. - Be patient, supportive, and focused on independent understanding. - `); - - case "parent": - return dedent(` - **User is a parent.** - - Help parents understand school topics, homework, study support, assessment, and school systems. - Be empathetic, clear, and practical. - `); - - case "administrator": - return dedent(` - **User is an administrator (school or organization).** - - Support policy, planning, reporting, standards, stakeholder communication, and operational decisions. - Be professional, analytical, and evidence-based. - `); - - default: - return dedent(` - **User identity is unknown.** - - Treat the user as a curious learner. - Be welcoming, clear, patient, and focused on their stated goal. - `); - } -} diff --git a/packages/ai/agents/research/prompt.ts b/packages/ai/agents/research/prompt.ts index ce2536ab29..da00aebd1c 100644 --- a/packages/ai/agents/research/prompt.ts +++ b/packages/ai/agents/research/prompt.ts @@ -2,16 +2,14 @@ import { createPrompt } from "@repo/ai/prompt/utils"; import type { AgentContext } from "@repo/ai/types/agents"; import type { Locale } from "@repo/utilities/locales"; -interface ResearchPromptProps { - context: AgentContext; - locale: Locale; -} - /** Builds the research agent prompt for source evidence collection. */ export function researchEvidencePrompt({ locale, context, -}: ResearchPromptProps) { +}: { + readonly context: AgentContext; + readonly locale: Locale; +}) { return createPrompt({ taskContext: ` # Identity @@ -76,7 +74,13 @@ export function researchEvidencePrompt({ } /** Builds the research agent prompt for structured source-backed synthesis. */ -export function researchPrompt({ locale, context }: ResearchPromptProps) { +export function researchPrompt({ + locale, + context, +}: { + readonly context: AgentContext; + readonly locale: Locale; +}) { return createPrompt({ taskContext: ` # Identity diff --git a/packages/ai/agents/research/search/scope.ts b/packages/ai/agents/research/search/scope.ts index 7b7778d8cd..0bda062fc0 100644 --- a/packages/ai/agents/research/search/scope.ts +++ b/packages/ai/agents/research/search/scope.ts @@ -1,3 +1,4 @@ +import type { WebSearchInput } from "@repo/ai/agents/research/schema"; import type { SearchSource } from "@repo/ai/agents/research/search/source"; import { extractDomain } from "@repo/ai/lib/domain"; import { @@ -20,7 +21,7 @@ export function scopeSources({ task, }: { query: string; - sourcePreference: "primary" | "any"; + sourcePreference: WebSearchInput["sourcePreference"]; sources: SearchSource[]; task: string; }) { @@ -63,7 +64,7 @@ function getPrimarySources({ task, }: { query: string; - sourcePreference: "primary" | "any"; + sourcePreference: WebSearchInput["sourcePreference"]; sources: SearchSource[]; task: string; }) { diff --git a/packages/ai/agents/research/tools/scrape.ts b/packages/ai/agents/research/tools/scrape.ts index 9c9eed20fa..94b768c218 100644 --- a/packages/ai/agents/research/tools/scrape.ts +++ b/packages/ai/agents/research/tools/scrape.ts @@ -12,14 +12,6 @@ import type { UIMessageStreamWriter } from "ai"; import dedent from "dedent"; import { Effect, Either } from "effect"; -interface ScrapeUrlParams { - maxLength?: number; - selectionQuery?: string; - toolCallId: string; - url: string; - writer: UIMessageStreamWriter; -} - /** * Scrapes one URL and returns structured evidence for citation checks. */ @@ -29,7 +21,13 @@ export const scrapeUrl = Effect.fn("research.scrapeUrl")(function* ({ toolCallId, url, writer, -}: ScrapeUrlParams) { +}: { + readonly maxLength?: number; + readonly selectionQuery?: string; + readonly toolCallId: string; + readonly url: string; + readonly writer: UIMessageStreamWriter; +}) { yield* Effect.sync(() => writer.write({ id: toolCallId, diff --git a/packages/ai/agents/research/tools/search.ts b/packages/ai/agents/research/tools/search.ts index 8b606f59dc..5d39e3e05e 100644 --- a/packages/ai/agents/research/tools/search.ts +++ b/packages/ai/agents/research/tools/search.ts @@ -1,4 +1,5 @@ import { + type WebSearchInput, type WebSearchOutput, webSearchMaxQueries, } from "@repo/ai/agents/research/schema"; @@ -26,7 +27,7 @@ export const searchWeb = Effect.fn("research.searchWeb")(function* ({ writer, }: { queries: readonly string[]; - sourcePreference: "primary" | "any"; + sourcePreference: WebSearchInput["sourcePreference"]; task: string; toolCallId: string; writer: UIMessageStreamWriter; diff --git a/packages/ai/config/timeouts.ts b/packages/ai/config/timeouts.ts index b2479268d1..c4b3ea0d65 100644 --- a/packages/ai/config/timeouts.ts +++ b/packages/ai/config/timeouts.ts @@ -27,3 +27,9 @@ export const backgroundGenerationTimeout = { stepMs: 15_000, totalMs: 45_000, } satisfies TimeoutConfiguration; + +/** Bounds Nina's post-answer follow-up suggestions without cutting off slow structured output. */ +export const suggestionGenerationTimeout = { + stepMs: 30_000, + totalMs: 90_000, +} satisfies TimeoutConfiguration; diff --git a/packages/ai/eval/runner.ts b/packages/ai/eval/runner.ts new file mode 100644 index 0000000000..75b5ffa915 --- /dev/null +++ b/packages/ai/eval/runner.ts @@ -0,0 +1,54 @@ +import { + type EvalCase, + EvalCaseResult, + EvalRun, + type EvalRunError, + type EvalSuite, +} from "@repo/ai/eval/spec"; +import { Clock, Context, Effect } from "effect"; + +/** Effect service that renders one eval case through the target Module seam. */ +export class EvalRenderer extends Context.Tag("EvalRenderer")< + EvalRenderer, + { + readonly render: ( + testCase: EvalCase + ) => Effect.Effect; + } +>() {} + +/** Checks one rendered eval case against its schema-owned expectations. */ +export function evaluateRenderedCase(testCase: EvalCase, rendered: string) { + const missing = testCase.expectations.flatMap((expectation) => + rendered.includes(expectation.includes) ? [] : [expectation.label] + ); + + return EvalCaseResult.make({ + id: testCase.id, + missing, + status: missing.length === 0 ? "passed" : "failed", + }); +} + +/** Runs a deterministic eval suite through the provided EvalRenderer service. */ +export const runEvalSuite = Effect.fn("eval.runSuite")(function* ( + suite: EvalSuite +) { + const renderer = yield* EvalRenderer; + const startedAt = yield* Clock.currentTimeMillis; + const results: EvalCaseResult[] = []; + + for (const testCase of suite.cases) { + const rendered = yield* renderer.render(testCase); + results.push(evaluateRenderedCase(testCase, rendered)); + } + + const endedAt = yield* Clock.currentTimeMillis; + + return EvalRun.make({ + endedAt, + results, + startedAt, + suite: suite.name, + }); +}); diff --git a/packages/ai/eval/spec.ts b/packages/ai/eval/spec.ts new file mode 100644 index 0000000000..563b479829 --- /dev/null +++ b/packages/ai/eval/spec.ts @@ -0,0 +1,57 @@ +import { Schema } from "effect"; + +/** Stable deterministic eval targets required for Nina readiness. */ +export const EvalTargetSchema = Schema.Literal( + "math", + "nakafa", + "research", + "trace", + "turn" +); + +/** One text expectation checked against a deterministic eval rendering. */ +export class EvalExpectation extends Schema.Class( + "EvalExpectation" +)({ + includes: Schema.NonEmptyString, + label: Schema.NonEmptyString, +}) {} + +/** Schema-owned deterministic eval case for NinaHarness and capabilities. */ +export class EvalCase extends Schema.Class("EvalCase")({ + expectations: Schema.Array(EvalExpectation).pipe(Schema.mutable), + id: Schema.NonEmptyString, + target: EvalTargetSchema, +}) {} + +/** Schema-owned collection of deterministic eval cases. */ +export class EvalSuite extends Schema.Class("EvalSuite")({ + cases: Schema.Array(EvalCase).pipe(Schema.mutable), + name: Schema.NonEmptyString, +}) {} + +/** Outcome for one deterministic eval case. */ +export class EvalCaseResult extends Schema.Class( + "EvalCaseResult" +)({ + id: Schema.NonEmptyString, + missing: Schema.Array(Schema.NonEmptyString).pipe(Schema.mutable), + status: Schema.Literal("passed", "failed"), +}) {} + +/** Schema-owned record for one deterministic eval suite execution. */ +export class EvalRun extends Schema.Class("EvalRun")({ + endedAt: Schema.Number, + results: Schema.Array(EvalCaseResult).pipe(Schema.mutable), + startedAt: Schema.Number, + suite: Schema.NonEmptyString, +}) {} + +/** Expected eval rendering or assertion failure. */ +export class EvalRunError extends Schema.TaggedError()( + "EvalRunError", + { + caseId: Schema.String, + message: Schema.String, + } +) {} diff --git a/packages/ai/eval/suites.test.ts b/packages/ai/eval/suites.test.ts new file mode 100644 index 0000000000..43eecb55bb --- /dev/null +++ b/packages/ai/eval/suites.test.ts @@ -0,0 +1,58 @@ +// @vitest-environment node + +import { + EvalRenderer, + evaluateRenderedCase, + runEvalSuite, +} from "@repo/ai/eval/runner"; +import { EvalCase, EvalExpectation } from "@repo/ai/eval/spec"; +import { createNinaEvalSuite, renderNinaEvalCase } from "@repo/ai/eval/suites"; +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; + +describe("nina deterministic eval suite", () => { + it("passes every local NinaHarness and LearningCapability eval case", async () => { + const suite = createNinaEvalSuite(); + const run = await Effect.runPromise( + runEvalSuite(suite).pipe( + Effect.provideService(EvalRenderer, { + render: (testCase) => Effect.succeed(renderNinaEvalCase(testCase)), + }) + ) + ); + + expect(run.suite).toBe("nina-deterministic"); + expect(run.results).toHaveLength(5); + expect(run.results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "math-deterministic-first" }), + expect.objectContaining({ id: "nakafa-evidence-boundary" }), + expect.objectContaining({ id: "research-source-boundary" }), + expect.objectContaining({ id: "trace-summary-boundary" }), + expect.objectContaining({ id: "turn-pinned-locale" }), + ]) + ); + expect(run.results.every((result) => result.status === "passed")).toBe( + true + ); + }); + + it("marks missing deterministic expectations as failed", () => { + const result = evaluateRenderedCase( + EvalCase.make({ + id: "missing-proof", + target: "trace", + expectations: [ + EvalExpectation.make({ + includes: "bounded trace evidence", + label: "requires trace evidence", + }), + ], + }), + "other output" + ); + + expect(result.status).toBe("failed"); + expect(result.missing).toEqual(["requires trace evidence"]); + }); +}); diff --git a/packages/ai/eval/suites.ts b/packages/ai/eval/suites.ts new file mode 100644 index 0000000000..f8d628d6fa --- /dev/null +++ b/packages/ai/eval/suites.ts @@ -0,0 +1,200 @@ +import { mathPrompt } from "@repo/ai/agents/math/prompt"; +import { nakafaAgentPrompt } from "@repo/ai/agents/nakafa/prompt"; +import { formatResearchOutput } from "@repo/ai/agents/research/output"; +import { researchPrompt } from "@repo/ai/agents/research/prompt"; +import { EvalCase, EvalExpectation, EvalSuite } from "@repo/ai/eval/spec"; +import { + CapabilityTrace, + EvidenceEnvelope, +} from "@repo/ai/nina/capability/spec"; +import { + type NinaPage, + readNinaLearningPage, +} from "@repo/ai/nina/contract/turn"; +import type { AgentContext } from "@repo/ai/types/agents"; + +const context = { + currentDate: "June 22, 2026", + needsPageFetch: false, + slug: "subjects/mathematics/vector/addition", + url: "https://nakafa.com/id/subjects/mathematics/vector/addition", + verified: true, +} satisfies AgentContext; + +/** Builds the deterministic local eval suite required before Nina readiness. */ +export function createNinaEvalSuite() { + return EvalSuite.make({ + name: "nina-deterministic", + cases: [ + EvalCase.make({ + id: "math-deterministic-first", + target: "math", + expectations: [ + EvalExpectation.make({ + label: "routes through deterministic tools", + includes: "route math work through deterministic math tools", + }), + EvalExpectation.make({ + label: "requires evidence before final claims", + includes: "If evidence is missing and can be checked", + }), + ], + }), + EvalCase.make({ + id: "nakafa-evidence-boundary", + target: "nakafa", + expectations: [ + EvalExpectation.make({ + label: "retrieves Nakafa-owned content", + includes: "retrieve Nakafa-owned content accurately", + }), + EvalExpectation.make({ + label: "does not invent practice", + includes: "say Nakafa did not return practice data", + }), + ], + }), + EvalCase.make({ + id: "research-source-boundary", + target: "research", + expectations: [ + EvalExpectation.make({ + label: "keeps citation data inline", + includes: "[AI SDK](https://ai-sdk.dev)", + }), + EvalExpectation.make({ + label: "rejects invented sources", + includes: "Do not invent sources or facts.", + }), + ], + }), + EvalCase.make({ + id: "trace-summary-boundary", + target: "trace", + expectations: [ + EvalExpectation.make({ + label: "persists summary only", + includes: "summary: deterministic evidence only", + }), + EvalExpectation.make({ + label: "keeps capability identity", + includes: "capability: math", + }), + ], + }), + EvalCase.make({ + id: "turn-pinned-locale", + target: "turn", + expectations: [ + EvalExpectation.make({ + label: "uses pinned learning locale", + includes: "locale: id", + }), + EvalExpectation.make({ + label: "uses pinned learning URL", + includes: + "url: https://nakafa.com/id/subjects/mathematics/vector/addition", + }), + ], + }), + ], + }); +} + +const ninaEvalRenderers = { + math: () => mathPrompt({ context, locale: "id" }), + nakafa: () => nakafaAgentPrompt({ context, locale: "id" }), + research: () => + [ + researchPrompt({ context, locale: "id" }), + formatResearchOutput({ + findings: [ + { + citations: [{ title: "AI SDK", url: "https://ai-sdk.dev" }], + text: "AI SDK supports structured generation.", + }, + ], + limitations: [], + noEvidenceAnswer: "No evidence.", + }), + ].join("\n\n"), + trace: () => { + const trace = CapabilityTrace.make({ + capability: "math", + durationMs: 12, + endedAt: 12, + evidence: EvidenceEnvelope.make({ + capability: "math", + status: "available", + summary: "deterministic evidence only", + }), + responseMessageIdentifier: "response-1", + startedAt: 0, + toolCallId: "tool-1", + }); + + return [ + `capability: ${trace.capability}`, + `status: ${trace.evidence.status}`, + `summary: ${trace.evidence.summary}`, + ].join("\n"); + }, + turn: () => { + const page = createPinnedLocalePage(); + const learning = readNinaLearningPage(page); + + return [`locale: ${learning.locale}`, `url: ${learning.url}`].join("\n"); + }, +}; + +/** Renders one deterministic eval case through the relevant Module seam. */ +export function renderNinaEvalCase(testCase: EvalCase) { + return ninaEvalRenderers[testCase.target](); +} + +/** Creates a turn fixture whose request locale differs from pinned learning. */ +function createPinnedLocalePage(): NinaPage { + return { + locale: "en", + needsFetch: false, + slug: "chat", + url: "https://nakafa.com/en/chat", + verified: false, + nina: { + learning: { + locale: "id", + slug: "subjects/mathematics/vector/addition", + url: "https://nakafa.com/id/subjects/mathematics/vector/addition", + verified: true, + }, + snapshot: { + capturedAt: "2026-06-22T00:00:00.000Z", + learning: { + locale: "id", + slug: "subjects/mathematics/vector/addition", + url: "https://nakafa.com/id/subjects/mathematics/vector/addition", + verified: true, + }, + source: "pinned-chat", + tools: { + allowDeepResearch: true, + allowMath: true, + allowNakafa: true, + allowPageFetch: true, + evidenceScope: "verified-page", + }, + }, + tools: { + allowDeepResearch: true, + allowMath: true, + allowNakafa: true, + allowPageFetch: true, + evidenceScope: "verified-page", + }, + transition: { + reason: "same-context", + toContextKey: "canonical:subjects/mathematics/vector/addition", + }, + }, + }; +} diff --git a/packages/ai/lib/source.ts b/packages/ai/lib/source.ts index 7b13c4c7c0..9850922f3e 100644 --- a/packages/ai/lib/source.ts +++ b/packages/ai/lib/source.ts @@ -1,5 +1,6 @@ import { getLatestUserText } from "@repo/ai/lib/user"; import type { ModelMessage } from "ai"; +import { Schema } from "effect"; import { ParseResultType, parseDomain } from "parse-domain"; const whitespacePattern = /\s+/; @@ -29,10 +30,14 @@ const pairedBoundaryPunctuation = new Map([ ["}", "{"], ]); -/** - * Source reference extracted from user-written text. - */ -export type SourceReference = ReturnType; +/** Runtime contract for one external source reference extracted from user text. */ +export const SourceReferenceSchema = Schema.Struct({ + href: Schema.String, + hostname: Schema.String, + text: Schema.String, +}).pipe(Schema.mutable); + +export type SourceReference = Schema.Schema.Type; /** * Extracts every unique external source reference from plain user text. diff --git a/packages/ai/nina/capability/catalog.ts b/packages/ai/nina/capability/catalog.ts new file mode 100644 index 0000000000..60976c3e57 --- /dev/null +++ b/packages/ai/nina/capability/catalog.ts @@ -0,0 +1,307 @@ +import { runMathAgent } from "@repo/ai/agents/math/agent"; +import { runNakafaAgent } from "@repo/ai/agents/nakafa/agent"; +import { NakafaSearch } from "@repo/ai/agents/nakafa/search"; +import { Nakafa } from "@repo/ai/agents/nakafa/service"; +import { read as readNakafa } from "@repo/ai/agents/nakafa/tools/read"; +import { runResearchAgent } from "@repo/ai/agents/research/agent"; +import type { ModelId } from "@repo/ai/config/model"; +import { getSourceReferencesFromMessages } from "@repo/ai/lib/source"; +import { + capabilityResult, + recordSpecialistUsage, + recoverSpecialistFailure, + specialistSuccess, +} from "@repo/ai/nina/capability/result"; +import { + MATH_CAPABILITY, + NAKAFA_CAPABILITY, + RESEARCH_CAPABILITY, +} from "@repo/ai/nina/capability/spec"; +import { traceLearningCapability } from "@repo/ai/nina/capability/trace"; +import { + decideNinaCapability, + deniedCapabilityResult, +} from "@repo/ai/nina/policy/capability"; +import { getCanonicalNakafaContentUrl } from "@repo/ai/nina/runtime/page"; +import { NinaReporter } from "@repo/ai/nina/runtime/report"; +import type { NinaToolSet } from "@repo/ai/nina/runtime/step"; +import { NinaStore } from "@repo/ai/nina/runtime/store"; +import type { trackUsage } from "@repo/ai/nina/runtime/usage"; +import { + formatSpecialistToolTask, + mathToolInputSchema, + nakafaToolInputSchema, + researchToolInputSchema, +} from "@repo/ai/schema/tools"; +import type { AgentContext } from "@repo/ai/types/agents"; +import type { MyUIMessage } from "@repo/ai/types/message"; +import { NakafaAgentContentRefInputSchema } from "@repo/contents/_lib/agent/schema/read"; +import type { Locale } from "@repo/contents/_types/content"; +import type { LogContext } from "@repo/utilities/logging/types"; +import { tool, type UIMessageStreamWriter } from "ai"; +import { Effect } from "effect"; + +type NinaUsage = Effect.Effect.Success>; + +/** + * Builds Nina's internal AI SDK tool catalog for one turn. + * + * App-owned content/search adapters enter through Effect services; AI SDK + * callback shapes and writer access remain inside the harness runtime. + */ +export const createNinaCapabilityCatalog = Effect.fn("nina.capability.catalog")( + function* ({ + context, + locale, + logContext, + modelId, + responseMessageIdentifier, + consumePageFetch, + usage, + writer, + }: { + readonly consumePageFetch: () => boolean; + readonly context: AgentContext; + readonly locale: Locale; + readonly logContext: LogContext; + readonly modelId: ModelId; + readonly responseMessageIdentifier: string; + readonly usage: NinaUsage; + readonly writer: UIMessageStreamWriter; + }) { + const nakafa = yield* Nakafa; + const search = yield* NakafaSearch; + const reporter = yield* NinaReporter; + const store = yield* NinaStore; + + return { + [NAKAFA_CAPABILITY]: tool({ + description: + "Retrieve Nakafa educational evidence for lessons, study topics, current pages, articles, Quran references, examples, warmups, review tasks, tryout preparation, and structured exercises. Use this before math when content must be selected. Preserve requested deliverables in the structured input.", + inputSchema: nakafaToolInputSchema, + /** Runs the Nakafa specialist with one-time current-page fetch support. */ + execute: (input, { toolCallId }) => + Effect.runPromise( + traceLearningCapability({ + capability: NAKAFA_CAPABILITY, + responseMessageIdentifier, + toolCallId, + run: Effect.gen(function* () { + const decision = decideNinaCapability({ + capability: NAKAFA_CAPABILITY, + context, + }); + if (decision.state !== "allowed") { + return deniedCapabilityResult({ + capability: NAKAFA_CAPABILITY, + decision, + }); + } + + const needsPageFetch = consumePageFetch(); + + if (needsPageFetch) { + const contentRef = getCanonicalNakafaContentUrl(context.url); + const text = yield* readNakafa({ + input: { + content_ref: + NakafaAgentContentRefInputSchema.make(contentRef), + }, + toolCallId, + writer, + }).pipe( + Effect.provideService(Nakafa, nakafa), + Effect.catchAll((error) => + recoverSpecialistFailure({ + component: NAKAFA_CAPABILITY, + error, + errorLocation: "readNakafaCurrentPage", + reporter, + }) + ) + ); + + if (typeof text !== "string") { + return text; + } + + return capabilityResult({ + capability: NAKAFA_CAPABILITY, + status: "available", + text, + }); + } + + const result = yield* runNakafaAgent({ + context: { ...context, needsPageFetch }, + locale, + modelId, + nakafa, + task: formatSpecialistToolTask(input), + writer, + }).pipe( + Effect.provideService(NakafaSearch, search), + Effect.map((result) => + specialistSuccess({ + capability: NAKAFA_CAPABILITY, + text: result.text, + usage: result.usage, + }) + ), + Effect.catchAll((error) => + recoverSpecialistFailure({ + component: NAKAFA_CAPABILITY, + error, + errorLocation: "runNakafaAgent", + reporter, + }) + ) + ); + + yield* recordSpecialistUsage({ + addUsage: usage.addUsage, + component: NAKAFA_CAPABILITY, + logContext, + result, + }); + + return result; + }), + }).pipe( + Effect.provideService(NinaReporter, reporter), + Effect.provideService(NinaStore, store), + Effect.map((result) => result.text) + ) + ), + }), + [RESEARCH_CAPABILITY]: tool({ + description: + "Research external, official, current, latest, cited, or source-backed information with web search and source analysis.", + inputSchema: researchToolInputSchema, + /** Runs the external research specialist and records its token usage. */ + execute: (input, { messages, toolCallId }) => + Effect.runPromise( + traceLearningCapability({ + capability: RESEARCH_CAPABILITY, + responseMessageIdentifier, + toolCallId, + run: Effect.gen(function* () { + const decision = decideNinaCapability({ + capability: RESEARCH_CAPABILITY, + context, + }); + if (decision.state !== "allowed") { + return deniedCapabilityResult({ + capability: RESEARCH_CAPABILITY, + decision, + }); + } + + const result = yield* runResearchAgent({ + context, + locale, + modelId, + task: formatSpecialistToolTask(input), + sourceReferences: getSourceReferencesFromMessages(messages), + toolCallId, + writer, + }).pipe( + Effect.map((result) => + specialistSuccess({ + capability: RESEARCH_CAPABILITY, + text: result.text, + usage: result.usage, + }) + ), + Effect.catchAll((error) => + recoverSpecialistFailure({ + component: RESEARCH_CAPABILITY, + error, + errorLocation: "runResearchAgent", + reporter, + }) + ) + ); + + yield* recordSpecialistUsage({ + addUsage: usage.addUsage, + component: RESEARCH_CAPABILITY, + logContext, + result, + }); + + return result; + }), + }).pipe( + Effect.provideService(NinaReporter, reporter), + Effect.provideService(NinaStore, store), + Effect.map((result) => result.text) + ) + ), + }), + [MATH_CAPABILITY]: tool({ + description: + "Verify user-provided or retrieved math with deterministic evidence for arithmetic, algebra, equations, calculus, series, matrices, statistics, probability, geometry, and discrete math. Do not use this as the first or only source for educational practice content; use Nakafa first, then math verifies the selected content.", + inputSchema: mathToolInputSchema, + /** Runs the deterministic math specialist and records its token usage. */ + execute: (input, { toolCallId }) => + Effect.runPromise( + traceLearningCapability({ + capability: MATH_CAPABILITY, + responseMessageIdentifier, + toolCallId, + run: Effect.gen(function* () { + const decision = decideNinaCapability({ + capability: MATH_CAPABILITY, + context, + }); + if (decision.state !== "allowed") { + return deniedCapabilityResult({ + capability: MATH_CAPABILITY, + decision, + }); + } + + const result = yield* runMathAgent({ + context, + locale, + modelId, + task: formatSpecialistToolTask(input), + writer, + }).pipe( + Effect.map((result) => + specialistSuccess({ + capability: MATH_CAPABILITY, + text: result.text, + usage: result.usage, + }) + ), + Effect.catchAll((error) => + recoverSpecialistFailure({ + component: MATH_CAPABILITY, + error, + errorLocation: "runMathAgent", + reporter, + }) + ) + ); + + yield* recordSpecialistUsage({ + addUsage: usage.addUsage, + component: MATH_CAPABILITY, + logContext, + result, + }); + + return result; + }), + }).pipe( + Effect.provideService(NinaReporter, reporter), + Effect.provideService(NinaStore, store), + Effect.map((result) => result.text) + ) + ), + }), + } satisfies NinaToolSet; + } +); diff --git a/apps/www/app/api/chat/specialist.test.ts b/packages/ai/nina/capability/result.test.ts similarity index 59% rename from apps/www/app/api/chat/specialist.test.ts rename to packages/ai/nina/capability/result.test.ts index 6ff474accd..7f637c1a54 100644 --- a/apps/www/app/api/chat/specialist.test.ts +++ b/packages/ai/nina/capability/result.test.ts @@ -1,13 +1,16 @@ // @vitest-environment node -import type { ToolName } from "@repo/ai/schema/tools"; -import type { LanguageModelUsage } from "ai"; -import { Effect, Logger, Option } from "effect"; -import { describe, expect, it } from "vitest"; + import { + capabilityResult, recordSpecialistUsage, recoverSpecialistFailure, specialistSuccess, -} from "@/app/api/chat/specialist"; +} from "@repo/ai/nina/capability/result"; +import type { LearningCapabilityName } from "@repo/ai/nina/capability/spec"; +import type { NinaReporter } from "@repo/ai/nina/runtime/report"; +import type { LanguageModelUsage } from "ai"; +import { type Context, Effect, Logger, Option } from "effect"; +import { describe, expect, it } from "vitest"; const usage = { inputTokens: 3, @@ -26,11 +29,14 @@ const usage = { /** Records usage rows passed through the specialist usage seam. */ function usageRecorder() { - const rows: { component: ToolName; usage: LanguageModelUsage }[] = []; + const rows: { + component: LearningCapabilityName; + usage: LanguageModelUsage; + }[] = []; return { rows, - addUsage: (component: ToolName, row: LanguageModelUsage) => + addUsage: (component: LearningCapabilityName, row: LanguageModelUsage) => Effect.sync(() => { rows.push({ component, usage: row }); }), @@ -45,10 +51,43 @@ function testLogger() { return { entries, logger }; } -describe("app/api/chat/specialist", () => { +describe("nina/capability/result", () => { + it("preserves optional evidence limitations and references", () => { + const result = capabilityResult({ + capability: "nakafa", + limitations: ["verified-page only"], + refs: ["https://nakafa.com/id/subjects/math"], + status: "limited", + text: "bounded content evidence", + }); + + expect(result.evidence.limitations).toEqual(["verified-page only"]); + expect(result.evidence.refs).toEqual([ + "https://nakafa.com/id/subjects/math", + ]); + expect(result.evidence.status).toBe("limited"); + }); + + it("stores a bounded evidence summary while preserving full model-facing text", () => { + const text = "verified evidence ".repeat(120); + const result = capabilityResult({ + capability: "nakafa", + status: "available", + text, + }); + + expect(result.text).toBe(text); + expect(result.evidence.summary.length).toBeLessThanOrEqual(1200); + expect(result.evidence.summary.endsWith("...")).toBe(true); + }); + it("preserves real usage for successful specialists", async () => { const tracker = usageRecorder(); - const result = specialistSuccess({ text: "verified", usage }); + const result = specialistSuccess({ + capability: "math", + text: "verified", + usage, + }); await Effect.runPromise( recordSpecialistUsage({ @@ -60,6 +99,8 @@ describe("app/api/chat/specialist", () => { ); expect(result.text).toBe("verified"); + expect(result.evidence.capability).toBe("math"); + expect(result.evidence.status).toBe("available"); expect(Option.isSome(result.usage)).toBe(true); expect(tracker.rows).toEqual([{ component: "math", usage }]); }); @@ -75,9 +116,7 @@ describe("app/api/chat/specialist", () => { component: "deepResearch", error: new Error("network unavailable"), errorLocation: "runResearchAgent", - reportError: (error) => { - reported.push(error); - }, + reporter: createReporter(reported), }); yield* recordSpecialistUsage({ @@ -94,6 +133,8 @@ describe("app/api/chat/specialist", () => { expect(reported).toHaveLength(1); expect(entries.map((entry) => entry.logLevel.label)).toEqual(["WARN"]); expect(Option.isNone(result.usage)).toBe(true); + expect(result.evidence.capability).toBe("deepResearch"); + expect(result.evidence.status).toBe("failed"); expect(tracker.rows).toEqual([]); expect(result.text).toContain("Specialist: deepResearch"); expect(result.text).toContain("Usage returned: none"); @@ -108,9 +149,7 @@ describe("app/api/chat/specialist", () => { component: "nakafa", error: "content lookup failed", errorLocation: "runNakafaAgent", - reportError: (error) => { - reported.push(error); - }, + reporter: createReporter(reported), }).pipe(Effect.provide(Logger.replace(Logger.defaultLogger, logger))) ); @@ -121,7 +160,19 @@ describe("app/api/chat/specialist", () => { } expect(entries.map((entry) => entry.logLevel.label)).toEqual([]); expect(Option.isNone(result.usage)).toBe(true); + expect(result.evidence.capability).toBe("nakafa"); + expect(result.evidence.status).toBe("failed"); expect(result.text).toContain("Specialist: nakafa"); expect(result.text).toContain("Do not invent facts"); }); }); + +/** Creates the diagnostics service used by specialist recovery tests. */ +function createReporter(reported: unknown[]) { + return { + report: ({ error }: { readonly error: unknown }) => + Effect.sync(() => { + reported.push(error); + }), + } satisfies Context.Tag.Service; +} diff --git a/packages/ai/nina/capability/result.ts b/packages/ai/nina/capability/result.ts new file mode 100644 index 0000000000..7733f29dd2 --- /dev/null +++ b/packages/ai/nina/capability/result.ts @@ -0,0 +1,172 @@ +import { + EvidenceEnvelope, + type LearningCapabilityName, + LearningCapabilityResult, +} from "@repo/ai/nina/capability/spec"; +import type { NinaReporter } from "@repo/ai/nina/runtime/report"; +import { createPrompt } from "@repo/ai/prompt/utils"; +import type { LogContext } from "@repo/utilities/logging/types"; +import type { LanguageModelUsage } from "ai"; +import type { Context, Effect as EffectType } from "effect"; +import { Effect, Option, Schema } from "effect"; + +type AddUsage = ( + component: LearningCapabilityName, + usage: LanguageModelUsage +) => EffectType.Effect; + +const maxEvidenceSummaryCharacters = 1200; + +/** Tagged diagnostic used when a specialist throws a non-Error value. */ +class SpecialistUnknownFailure extends Schema.TaggedError()( + "SpecialistUnknownFailure", + { + message: Schema.String, + } +) {} + +/** Builds the common model-facing result for one LearningCapability execution. */ +export function capabilityResult({ + capability, + limitations, + refs, + status, + text, +}: { + readonly capability: LearningCapabilityName; + readonly limitations?: readonly string[]; + readonly refs?: readonly string[]; + readonly status: EvidenceEnvelope["status"]; + readonly text: string; +}) { + return LearningCapabilityResult.make({ + evidence: EvidenceEnvelope.make({ + capability, + ...(limitations ? { limitations: [...limitations] } : {}), + ...(refs ? { refs: [...refs] } : {}), + status, + summary: summarizeCapabilityEvidence(text), + }), + text, + }); +} + +/** Keeps persisted capability evidence useful without storing raw transcripts. */ +function summarizeCapabilityEvidence(text: string) { + if (text.length <= maxEvidenceSummaryCharacters) { + return text; + } + + return `${text.slice(0, maxEvidenceSummaryCharacters - 3)}...`; +} + +/** Converts completed LearningCapability evidence into the chat tool result shape. */ +export function specialistSuccess({ + capability, + text, + usage, +}: { + readonly capability: LearningCapabilityName; + readonly text: string; + readonly usage: LanguageModelUsage; +}) { + return { + ...capabilityResult({ + capability, + status: "available", + text, + }), + usage: Option.some(usage), + }; +} + +/** Records specialist usage only when the model returned real usage data. */ +export const recordSpecialistUsage = Effect.fn("nina.specialist.usage")( + function* ({ + addUsage, + component, + logContext, + result, + }: { + readonly addUsage: AddUsage; + readonly component: LearningCapabilityName; + readonly logContext: LogContext; + readonly result: ReturnType; + }) { + yield* Effect.annotateCurrentSpan("component", component); + + if (Option.isNone(result.usage)) { + yield* Effect.logWarning("Specialist usage unavailable").pipe( + Effect.annotateLogs({ + ...logContext, + component, + type: "specialist_usage", + usageAvailable: false, + }) + ); + return; + } + + yield* addUsage(component, result.usage.value); + } +); + +/** Turns a specialist failure into model-facing recovery evidence. */ +export const recoverSpecialistFailure = Effect.fn("nina.specialist.recover")( + function* ({ + component, + error, + errorLocation, + reporter, + }: { + readonly component: LearningCapabilityName; + readonly error: unknown; + readonly errorLocation: string; + readonly reporter: Context.Tag.Service; + }) { + const normalizedError = normalizeError(error); + + yield* Effect.annotateCurrentSpan("component", component); + yield* Effect.annotateCurrentSpan("errorLocation", errorLocation); + yield* reporter.report({ error: normalizedError, source: errorLocation }); + + return { + ...capabilityResult({ + capability: component, + limitations: [String(normalizedError.message)], + status: "failed", + text: formatSpecialistFailure(component), + }), + usage: Option.none(), + }; + } +); + +/** Normalizes unknown thrown values into structured diagnostics for logs. */ +function normalizeError(error: unknown) { + if (error instanceof Error) { + return error; + } + + return new SpecialistUnknownFailure({ message: String(error) }); +} + +/** Builds a compact model-facing status for a failed LearningCapability call. */ +function formatSpecialistFailure(component: LearningCapabilityName) { + return createPrompt({ + taskContext: [ + "# Specialist Status", + "", + `- Specialist: ${component}`, + "- Status: error", + "- Evidence returned: none", + "- Usage returned: none", + "", + "# Final Answer Constraint", + "", + "Use only evidence already present in this conversation.", + "Do not invent facts from the failed specialist.", + "Do not copy this status block into the final answer.", + ].join("\n"), + }); +} diff --git a/packages/ai/nina/capability/spec.test.ts b/packages/ai/nina/capability/spec.test.ts new file mode 100644 index 0000000000..5fbabdd2c6 --- /dev/null +++ b/packages/ai/nina/capability/spec.test.ts @@ -0,0 +1,46 @@ +// @vitest-environment node + +import { + CapabilityTrace, + EvidenceEnvelope, + encodeCapabilityTrace, +} from "@repo/ai/nina/capability/spec"; +import { describe, expect, it } from "vitest"; + +describe("nina/capability/spec", () => { + it("encodes schema class traces into plain operational values", () => { + const trace = CapabilityTrace.make({ + capability: "math", + durationMs: 12, + endedAt: 1_772_007_212, + evidence: EvidenceEnvelope.make({ + capability: "math", + refs: ["nakafa://content/math"], + status: "available", + summary: "2 + 3 = 5", + }), + responseMessageIdentifier: "response-1", + startedAt: 1_772_007_200, + toolCallId: "tool-1", + }); + + const encoded = encodeCapabilityTrace(trace); + + expect(encoded).toEqual({ + capability: "math", + durationMs: 12, + endedAt: 1_772_007_212, + evidence: { + capability: "math", + refs: ["nakafa://content/math"], + status: "available", + summary: "2 + 3 = 5", + }, + responseMessageIdentifier: "response-1", + startedAt: 1_772_007_200, + toolCallId: "tool-1", + }); + expect(Object.getPrototypeOf(encoded)).toBe(Object.prototype); + expect(Object.getPrototypeOf(encoded.evidence)).toBe(Object.prototype); + }); +}); diff --git a/packages/ai/nina/capability/spec.ts b/packages/ai/nina/capability/spec.ts new file mode 100644 index 0000000000..163077fe01 --- /dev/null +++ b/packages/ai/nina/capability/spec.ts @@ -0,0 +1,99 @@ +import { Schema } from "effect"; + +export const LEARNING_CAPABILITY_NAME_VALUES = [ + "nakafa", + "deepResearch", + "math", +] as const; + +/** Schema-owned names for Nina's internal education capabilities. */ +export const LearningCapabilityNameSchema = Schema.Literal( + ...LEARNING_CAPABILITY_NAME_VALUES +); + +export type LearningCapabilityName = Schema.Schema.Type< + typeof LearningCapabilityNameSchema +>; + +export const NAKAFA_CAPABILITY = "nakafa" satisfies LearningCapabilityName; +export const RESEARCH_CAPABILITY = + "deepResearch" satisfies LearningCapabilityName; +export const MATH_CAPABILITY = "math" satisfies LearningCapabilityName; + +export const EVIDENCE_STATUS_VALUES = [ + "available", + "limited", + "failed", + "denied", +] as const; + +/** Bounded status values that describe whether evidence may constrain Nina. */ +export const EvidenceStatusSchema = Schema.Literal(...EVIDENCE_STATUS_VALUES); + +/** + * Schema-owned evidence envelope returned by a LearningCapability. + * + * The summary is the compact model-visible evidence. References and + * limitations stay bounded so future traces can persist summaries without + * storing raw specialist transcripts. + */ +export class EvidenceEnvelope extends Schema.Class( + "EvidenceEnvelope" +)({ + capability: LearningCapabilityNameSchema, + limitations: Schema.optional( + Schema.Array(Schema.NonEmptyString).pipe(Schema.mutable) + ), + refs: Schema.optional(Schema.Array(Schema.String).pipe(Schema.mutable)), + status: EvidenceStatusSchema, + summary: Schema.String, +}) {} + +/** + * Minimal LearningCapability result shape that can be rendered to a model and + * summarized in operational traces. + */ +export class LearningCapabilityResult extends Schema.Class( + "LearningCapabilityResult" +)({ + evidence: EvidenceEnvelope, + text: Schema.String, +}) {} + +/** + * Bounded operational trace for one LearningCapability execution. + * + * This intentionally stores evidence summaries and references, not raw chat + * transcripts or full model/tool payloads. + */ +export class CapabilityTrace extends Schema.Class( + "CapabilityTrace" +)({ + capability: LearningCapabilityNameSchema, + durationMs: Schema.Number, + endedAt: Schema.Number, + evidence: EvidenceEnvelope, + responseMessageIdentifier: Schema.String, + startedAt: Schema.Number, + toolCallId: Schema.optional(Schema.String), +}) {} + +export type CapabilityTraceEncoded = Schema.Schema.Encoded< + typeof CapabilityTrace +>; + +/** Encodes a schema-owned trace instance into a plain operational data value. */ +export function encodeCapabilityTrace( + trace: CapabilityTrace +): CapabilityTraceEncoded { + return Schema.encodeSync(CapabilityTrace)(trace); +} + +/** Expected LearningCapability failure surfaced through the Effect channel. */ +export class LearningCapabilityError extends Schema.TaggedError()( + "LearningCapabilityError", + { + capability: LearningCapabilityNameSchema, + message: Schema.String, + } +) {} diff --git a/packages/ai/nina/capability/trace.ts b/packages/ai/nina/capability/trace.ts new file mode 100644 index 0000000000..687922f253 --- /dev/null +++ b/packages/ai/nina/capability/trace.ts @@ -0,0 +1,54 @@ +import { + CapabilityTrace, + type LearningCapabilityName, + type LearningCapabilityResult, +} from "@repo/ai/nina/capability/spec"; +import { NinaReporter } from "@repo/ai/nina/runtime/report"; +import { NinaStore } from "@repo/ai/nina/runtime/store"; +import { Clock, Effect } from "effect"; + +/** + * Runs one LearningCapability and persists a bounded operational trace. + * + * Trace persistence is best-effort operational data: failures are reported but + * never replace the capability evidence that Nina needs for the user answer. + */ +export const traceLearningCapability = Effect.fn("nina.capability.trace")( + function* ({ + capability, + responseMessageIdentifier, + run, + toolCallId, + }: { + readonly capability: LearningCapabilityName; + readonly responseMessageIdentifier: string; + readonly run: Effect.Effect; + readonly toolCallId?: string; + }) { + const store = yield* NinaStore; + const reporter = yield* NinaReporter; + const startedAt = yield* Clock.currentTimeMillis; + const result = yield* run; + const endedAt = yield* Clock.currentTimeMillis; + + yield* store + .saveTrace( + CapabilityTrace.make({ + capability, + durationMs: Math.max(0, endedAt - startedAt), + endedAt, + evidence: result.evidence, + responseMessageIdentifier, + startedAt, + ...(toolCallId ? { toolCallId } : {}), + }) + ) + .pipe( + Effect.catchAll((error) => + reporter.report({ error, source: "saveCapabilityTrace" }) + ) + ); + + return result; + } +); diff --git a/packages/ai/nina/contract/turn.ts b/packages/ai/nina/contract/turn.ts new file mode 100644 index 0000000000..c892bb313d --- /dev/null +++ b/packages/ai/nina/contract/turn.ts @@ -0,0 +1,99 @@ +import { ModelIdSchema } from "@repo/ai/config/model"; +import { NinaContextPackSchema } from "@repo/ai/nina/memory/pack"; +import { + type AgentContext, + AgentLearningProfileSchema, +} from "@repo/ai/types/agents"; +import { PromptUserRoleSchema } from "@repo/ai/types/roles"; +import { LocaleSchema } from "@repo/contents/_types/content"; +import { cleanSlug } from "@repo/utilities/helper"; +import { Schema } from "effect"; + +/** Verified learning page state consumed by one Nina harness turn. */ +export const NinaPageSchema = Schema.Struct({ + locale: LocaleSchema, + needsFetch: Schema.Boolean, + nina: NinaContextPackSchema, + slug: Schema.String, + url: Schema.String, + verified: Schema.Boolean, +}).pipe(Schema.mutable); + +/** Runtime facts that are stable for one Nina harness turn. */ +export const NinaRuntimeSchema = Schema.Struct({ + currentDate: Schema.String, + modelId: ModelIdSchema, +}).pipe(Schema.mutable); + +/** Coarse user-location facts allowed in Nina prompt context. */ +export const NinaLocationSchema = Schema.Struct({ + city: Schema.String, + country: Schema.String, + countryRegion: Schema.String, + latitude: Schema.String, + longitude: Schema.String, +}).pipe(Schema.mutable); + +/** User facts Nina may use after app auth/profile boundaries validate them. */ +export const NinaUserSchema = Schema.Struct({ + learningProfile: Schema.optional(AgentLearningProfileSchema), + location: NinaLocationSchema, + role: Schema.optional(PromptUserRoleSchema), +}).pipe(Schema.mutable); + +/** Browser-facing localized copy needed by AI SDK stream error handlers. */ +export const NinaCopySchema = Schema.Struct({ + errorMessage: Schema.String, + rateLimitMessage: Schema.String, +}).pipe(Schema.mutable); + +/** Single schema-owned input accepted by the external NinaHarness Interface. */ +export const NinaTurnSchema = Schema.Struct({ + copy: NinaCopySchema, + page: NinaPageSchema, + runtime: NinaRuntimeSchema, + user: NinaUserSchema, +}).pipe(Schema.mutable); + +export type NinaPage = Schema.Schema.Type; +export type NinaRuntime = Schema.Schema.Type; +export type NinaLocation = Schema.Schema.Type; +export type NinaUser = Schema.Schema.Type; +export type NinaCopy = Schema.Schema.Type; +export type NinaTurn = Schema.Schema.Type; + +/** Returns the immutable learning page that should drive one Nina turn. */ +export function readNinaLearningPage(page: NinaPage) { + const learning = page.nina.learning; + + return { + locale: learning.locale, + slug: cleanSlug(learning.slug), + url: learning.url, + verified: learning.verified, + }; +} + +/** Builds the shared specialist context from validated Nina turn inputs. */ +export function createNinaAgentContext({ + page, + runtime, + user, +}: { + readonly page: NinaPage; + readonly runtime: NinaRuntime; + readonly user: NinaUser; +}): AgentContext { + const learningPage = readNinaLearningPage(page); + + return { + currentDate: runtime.currentDate, + needsPageFetch: page.needsFetch, + nina: page.nina, + slug: learningPage.slug, + url: learningPage.url, + verified: learningPage.verified, + ...(user.learningProfile ? { learningProfile: user.learningProfile } : {}), + ...(user.role ? { userRole: user.role } : {}), + }; +} diff --git a/packages/ai/nina/harness/stream.test.ts b/packages/ai/nina/harness/stream.test.ts new file mode 100644 index 0000000000..a5302b2e16 --- /dev/null +++ b/packages/ai/nina/harness/stream.test.ts @@ -0,0 +1,166 @@ +import { NakafaSearch } from "@repo/ai/agents/nakafa/search"; +import { Nakafa } from "@repo/ai/agents/nakafa/service"; +import { createNakafaTestService } from "@repo/ai/agents/nakafa/tools/test"; +import { ModelIdSchema } from "@repo/ai/config/model"; +import type { NinaTurn } from "@repo/ai/nina/contract/turn"; +import { + NinaHarness, + NinaHarnessInputError, +} from "@repo/ai/nina/harness/stream"; +import { NinaReporter } from "@repo/ai/nina/runtime/report"; +import { NinaStore } from "@repo/ai/nina/runtime/store"; +import { LearningProgramKeySchema } from "@repo/contents/_types/program/schema"; +import { Cause, Effect, Exit, Option } from "effect"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const createNinaStreamResponseMock = vi.hoisted(() => vi.fn()); + +vi.mock("@repo/ai/nina/runtime/stream", () => ({ + createNinaStreamResponse: createNinaStreamResponseMock, +})); + +const modelId = ModelIdSchema.make("nakafa-lite"); +const programKey = LearningProgramKeySchema.make("cambridge-lower-secondary"); + +const turn = { + copy: { + errorMessage: "Something went wrong.", + rateLimitMessage: "Please try again later.", + }, + page: { + locale: "en", + needsFetch: false, + nina: { + learning: { + assetId: "asset:id:material:math:vector:addition", + locale: "en", + slug: "subjects/mathematics/vector/addition", + title: "Vector Addition", + url: "https://nakafa.com/en/subjects/mathematics/vector/addition", + verified: true, + }, + placement: { + mode: "placement", + nodeKey: "curriculum:vector:addition", + parentHref: "/en/curriculum/mathematics/vector", + parentTitle: "Vector", + programKey, + }, + snapshot: { + capturedAt: "2026-06-22T00:00:00.000Z", + learning: { + assetId: "asset:id:material:math:vector:addition", + locale: "en", + slug: "subjects/mathematics/vector/addition", + title: "Vector Addition", + url: "https://nakafa.com/en/subjects/mathematics/vector/addition", + verified: true, + }, + placement: { + mode: "placement", + nodeKey: "curriculum:vector:addition", + parentHref: "/en/curriculum/mathematics/vector", + parentTitle: "Vector", + programKey, + }, + source: "current-page", + tools: { + allowDeepResearch: true, + allowMath: true, + allowNakafa: true, + allowPageFetch: true, + evidenceScope: "verified-page", + }, + }, + tools: { + allowDeepResearch: true, + allowMath: true, + allowNakafa: true, + allowPageFetch: true, + evidenceScope: "verified-page", + }, + transition: { + reason: "page-context", + toContextKey: + "placement:cambridge-lower-secondary:curriculum:vector:addition:subjects/mathematics/vector/addition", + }, + }, + slug: "/subjects/mathematics/vector/addition", + url: "https://nakafa.com/en/subjects/mathematics/vector/addition", + verified: true, + }, + runtime: { + currentDate: "June 22, 2026", + modelId, + }, + user: { + location: { + city: "Jakarta", + country: "Indonesia", + countryRegion: "Jakarta", + latitude: "-6.2", + longitude: "106.8", + }, + role: "student", + }, +} satisfies NinaTurn; + +describe("nina/harness/stream", () => { + beforeEach(() => { + createNinaStreamResponseMock.mockReset(); + createNinaStreamResponseMock.mockImplementation((input: NinaTurn) => + Effect.succeed(new Response(input.page.slug)) + ); + }); + + it("decodes one turn and delegates response creation through the harness Interface", async () => { + const response = await Effect.runPromise( + provideHarnessServices(NinaHarness.stream(turn)) + ); + + await expect(response.text()).resolves.toBe( + "/subjects/mathematics/vector/addition" + ); + expect(createNinaStreamResponseMock).toHaveBeenCalledWith(turn); + }); + + it("rejects invalid route input with a tagged harness error", async () => { + const exit = await Effect.runPromiseExit( + provideHarnessServices(NinaHarness.stream({ ...turn, page: undefined })) + ); + + expect(Exit.isFailure(exit)).toBe(true); + const failure = Exit.isFailure(exit) + ? Cause.failureOption(exit.cause) + : Option.none(); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) { + expect(failure.value).toBeInstanceOf(NinaHarnessInputError); + expect(failure.value.message).toBe("Invalid Nina harness turn input."); + } + }); +}); + +/** Provides the app-owned services required by the default NinaHarness layer. */ +function provideHarnessServices( + program: Effect.Effect +) { + return program.pipe( + Effect.provide(NinaHarness.Default), + Effect.provideService(NinaStore, { + loadMessages: () => Effect.succeed([]), + saveAssistant: () => Effect.void, + saveFailure: () => Effect.void, + saveTrace: () => Effect.void, + saveTitle: () => Effect.void, + }), + Effect.provideService(NinaReporter, { + report: () => Effect.void, + }), + Effect.provideService(Nakafa, createNakafaTestService()), + Effect.provideService(NakafaSearch, { + search: () => + Effect.dieMessage("Nakafa search is not used in this test."), + }) + ); +} diff --git a/packages/ai/nina/harness/stream.ts b/packages/ai/nina/harness/stream.ts new file mode 100644 index 0000000000..8a97396873 --- /dev/null +++ b/packages/ai/nina/harness/stream.ts @@ -0,0 +1,55 @@ +import { NakafaSearch } from "@repo/ai/agents/nakafa/search"; +import { Nakafa } from "@repo/ai/agents/nakafa/service"; +import { NinaTurnSchema } from "@repo/ai/nina/contract/turn"; +import { NinaReporter } from "@repo/ai/nina/runtime/report"; +import { NinaStore } from "@repo/ai/nina/runtime/store"; +import { createNinaStreamResponse } from "@repo/ai/nina/runtime/stream"; +import { Effect, Schema } from "effect"; + +/** Raised when a framework boundary sends an invalid Nina harness input. */ +export class NinaHarnessInputError extends Schema.TaggedError()( + "NinaHarnessInputError", + { + message: Schema.String, + } +) {} + +/** + * External Nina harness Interface used by framework routes. + * + * Route code supplies request/auth data and app-owned service adapters, while + * ToolLoopAgent, AI SDK writer callbacks, tool policy, and response composition + * remain inside this package-owned Module. + */ +export class NinaHarness extends Effect.Service()( + "@repo/ai/NinaHarness", + { + accessors: true, + effect: Effect.gen(function* () { + const store = yield* NinaStore; + const reporter = yield* NinaReporter; + const nakafa = yield* Nakafa; + const search = yield* NakafaSearch; + + return { + stream: Effect.fn("nina.harness.stream")(function* (input: unknown) { + const turn = yield* Schema.decodeUnknown(NinaTurnSchema)(input).pipe( + Effect.mapError( + () => + new NinaHarnessInputError({ + message: "Invalid Nina harness turn input.", + }) + ) + ); + + return yield* createNinaStreamResponse(turn).pipe( + Effect.provideService(NinaStore, store), + Effect.provideService(NinaReporter, reporter), + Effect.provideService(Nakafa, nakafa), + Effect.provideService(NakafaSearch, search) + ); + }), + }; + }), + } +) {} diff --git a/packages/ai/nina/memory/pack.test.ts b/packages/ai/nina/memory/pack.test.ts new file mode 100644 index 0000000000..7c56d96e2c --- /dev/null +++ b/packages/ai/nina/memory/pack.test.ts @@ -0,0 +1,94 @@ +import { + type NinaLearningSessionInput, + openNinaLearningSession, +} from "@repo/ai/nina/memory/pack"; +import { LearningProgramKeySchema } from "@repo/contents/_types/program/schema"; +import { Effect, Exit } from "effect"; +import { describe, expect, it } from "vitest"; + +const learning = { + assetId: "asset:id:material:mathematics:vector:addition", + contentId: "asset:id:material:mathematics:vector:addition", + locale: "en", + materialKey: "mathematics", + section: "subject-lesson", + slug: "subjects/mathematics/vector/addition", + sourcePath: "material/lesson/mathematics/vector/addition", + title: "Vector Addition", + url: "https://nakafa.com/en/subjects/mathematics/vector/addition", + verified: true, +} satisfies NinaLearningSessionInput["learning"]; +const placementProgramKey = LearningProgramKeySchema.make( + "cambridge-lower-secondary" +); + +describe("nina/memory/pack", () => { + it("opens a verified page session with a durable snapshot and page-fetch policy", async () => { + const session = await Effect.runPromise( + openNinaLearningSession({ + capturedAt: "2026-05-09T00:00:00.000Z", + learning, + placement: { + mode: "placement", + nodeKey: "curriculum:vector:addition", + parentHref: "/en/curriculum/mathematics/vector", + parentTitle: "Vector", + programKey: placementProgramKey, + }, + source: "current-page", + } satisfies NinaLearningSessionInput) + ); + + expect(session.context.snapshot).toMatchObject({ + capturedAt: "2026-05-09T00:00:00.000Z", + learning, + source: "current-page", + tools: { + allowPageFetch: true, + evidenceScope: "verified-page", + }, + }); + expect(session.context.transition).toEqual({ + reason: "page-context", + toContextKey: + "placement:cambridge-lower-secondary:curriculum:vector:addition:subjects/mathematics/vector/addition", + }); + }); + + it("keeps invalid session input in the Effect failure channel", async () => { + const exit = await Effect.runPromiseExit( + openNinaLearningSession({ + capturedAt: "2026-05-09T00:00:00.000Z", + learning: { + ...learning, + locale: "fr", + }, + source: "current-page", + }) + ); + + expect(Exit.isFailure(exit)).toBe(true); + }); + + it("opens an unverified canonical session without current-page fetch permission", async () => { + const session = await Effect.runPromise( + openNinaLearningSession({ + capturedAt: "2026-05-09T00:00:00.000Z", + learning: { + ...learning, + verified: false, + }, + source: "pinned-chat", + } satisfies NinaLearningSessionInput) + ); + + expect(session.context.snapshot.tools).toMatchObject({ + allowPageFetch: false, + evidenceScope: "general-learning", + }); + expect(session.context.transition).toEqual({ + reason: "same-context", + toContextKey: "canonical:subjects/mathematics/vector/addition", + }); + }); +}); diff --git a/packages/ai/nina/memory/pack.ts b/packages/ai/nina/memory/pack.ts new file mode 100644 index 0000000000..0bfd5ac0ce --- /dev/null +++ b/packages/ai/nina/memory/pack.ts @@ -0,0 +1,224 @@ +import { LocaleSchema } from "@repo/contents/_types/content"; +import { LearningProgramKeySchema } from "@repo/contents/_types/program/schema"; +import { Effect, Schema } from "effect"; + +export const NINA_CONTEXT_TRANSITION_REASONS = [ + "same-context", + "page-context", +] as const; + +export const NINA_CONTEXT_SOURCES = [ + "current-page", + "pinned-chat", + "message", +] as const; + +/** Page identity Nina can trust because the app validated it before the turn. */ +export const NinaLearningContextSchema = Schema.Struct({ + assetId: Schema.optional(Schema.String), + contentId: Schema.optional(Schema.String), + locale: LocaleSchema, + materialKey: Schema.optional(Schema.String), + section: Schema.optional(Schema.String), + slug: Schema.String, + sourcePath: Schema.optional(Schema.String), + title: Schema.optional(Schema.String), + url: Schema.String, + verified: Schema.Boolean, +}).pipe(Schema.mutable); + +/** Verified placement that explains why this asset was opened from navigation. */ +export const LearningPlacementContextSchema = Schema.Struct({ + mode: Schema.Literal("placement"), + nodeKey: Schema.String, + parentHref: Schema.String, + parentTitle: Schema.String, + programKey: LearningProgramKeySchema, +}).pipe(Schema.mutable); + +/** Tool permissions for a Nina turn, separated from tool implementation code. */ +export const NinaToolContextSchema = Schema.Struct({ + allowDeepResearch: Schema.Boolean, + allowMath: Schema.Boolean, + allowNakafa: Schema.Boolean, + allowPageFetch: Schema.Boolean, + evidenceScope: Schema.Literal("verified-page", "general-learning"), +}).pipe(Schema.mutable); + +/** Compact context copy that can be stored on messages and replayed later. */ +export const NinaContextSnapshotSchema = Schema.Struct({ + learning: NinaLearningContextSchema, + placement: Schema.optional(LearningPlacementContextSchema), + capturedAt: Schema.String, + source: Schema.Literal(...NINA_CONTEXT_SOURCES), + tools: NinaToolContextSchema, +}).pipe(Schema.mutable); + +/** Explicit marker for messages that intentionally switch Nina context. */ +export const NinaContextTransitionSchema = Schema.Struct({ + fromContextKey: Schema.optional(Schema.String), + reason: Schema.Literal(...NINA_CONTEXT_TRANSITION_REASONS), + toContextKey: Schema.String, +}).pipe(Schema.mutable); + +/** Input accepted by NinaHarness when opening one learning chat turn. */ +export const NinaLearningSessionInputSchema = Schema.Struct({ + capturedAt: Schema.String, + learning: NinaLearningContextSchema, + placement: Schema.optional(LearningPlacementContextSchema), + source: Schema.Literal(...NINA_CONTEXT_SOURCES), +}).pipe(Schema.mutable); + +export type NinaLearningContext = Schema.Schema.Type< + typeof NinaLearningContextSchema +>; +export type LearningPlacementContext = Schema.Schema.Type< + typeof LearningPlacementContextSchema +>; +export type NinaToolContext = Schema.Schema.Type; +export type NinaContextSnapshot = Schema.Schema.Type< + typeof NinaContextSnapshotSchema +>; +export type NinaContextTransition = Schema.Schema.Type< + typeof NinaContextTransitionSchema +>; +export type NinaLearningSessionInput = Schema.Schema.Type< + typeof NinaLearningSessionInputSchema +>; + +/** Nina context pack consumed by prompts, specialists, and message metadata. */ +export const NinaContextPackSchema = Schema.Struct({ + learning: NinaLearningContextSchema, + placement: Schema.optional(LearningPlacementContextSchema), + snapshot: NinaContextSnapshotSchema, + tools: NinaToolContextSchema, + transition: NinaContextTransitionSchema, +}).pipe(Schema.mutable); + +export type NinaContextPack = Schema.Schema.Type; + +/** NinaHarness output returned to app route boundaries for one turn. */ +export const NinaLearningSessionSchema = Schema.Struct({ + context: NinaContextPackSchema, +}).pipe(Schema.mutable); + +export type NinaLearningSession = Schema.Schema.Type< + typeof NinaLearningSessionSchema +>; + +/** Raised when the app boundary sends an invalid Nina learning context. */ +export class NinaContextError extends Schema.TaggedError()( + "NinaContextError", + { + message: Schema.String, + } +) {} + +/** Creates the stable key used for context snapshots and transition markers. */ +export function createNinaContextKey({ + learning, + placement, +}: { + readonly learning: NinaLearningContext; + readonly placement?: LearningPlacementContext; +}) { + if (placement) { + return `placement:${placement.programKey}:${placement.nodeKey}:${learning.slug}`; + } + + return `canonical:${learning.slug}`; +} + +/** Resolves per-turn specialist evidence permissions from validated context. */ +export function resolveNinaToolContext( + learning: NinaLearningContext +): NinaToolContext { + const allowPageFetch = learning.verified; + + return { + allowDeepResearch: true, + allowMath: true, + allowNakafa: true, + allowPageFetch, + evidenceScope: allowPageFetch ? "verified-page" : "general-learning", + }; +} + +/** Builds the durable snapshot that survives chat reload and retries. */ +export function createNinaContextSnapshot({ + capturedAt, + learning, + placement, + source, + tools, +}: { + readonly capturedAt: string; + readonly learning: NinaLearningContext; + readonly placement?: LearningPlacementContext; + readonly source: NinaContextSnapshot["source"]; + readonly tools: NinaToolContext; +}): NinaContextSnapshot { + return { + capturedAt, + learning, + placement, + source, + tools, + }; +} + +/** Builds an explicit transition marker for the current Nina turn. */ +export function createNinaContextTransition({ + learning, + placement, + reason, +}: { + readonly learning: NinaLearningContext; + readonly placement?: LearningPlacementContext; + readonly reason: NinaContextTransition["reason"]; +}): NinaContextTransition { + return { + reason, + toContextKey: createNinaContextKey({ learning, placement }), + }; +} + +/** Opens one validated Nina learning session as an Effect-native program. */ +export const openNinaLearningSession = Effect.fn( + "nina.openNinaLearningSession" +)(function* (input: unknown) { + const sessionInput = yield* Schema.decodeUnknown( + NinaLearningSessionInputSchema + )(input).pipe( + Effect.mapError( + () => + new NinaContextError({ + message: "Invalid Nina learning session input.", + }) + ) + ); + const tools = resolveNinaToolContext(sessionInput.learning); + const snapshot = createNinaContextSnapshot({ + capturedAt: sessionInput.capturedAt, + learning: sessionInput.learning, + placement: sessionInput.placement, + source: sessionInput.source, + tools, + }); + const transition = createNinaContextTransition({ + learning: sessionInput.learning, + placement: sessionInput.placement, + reason: + sessionInput.source === "pinned-chat" ? "same-context" : "page-context", + }); + + return { + context: { + learning: sessionInput.learning, + placement: sessionInput.placement, + snapshot, + tools, + transition, + }, + }; +}); diff --git a/packages/ai/nina/policy/capability.ts b/packages/ai/nina/policy/capability.ts new file mode 100644 index 0000000000..dda25892cd --- /dev/null +++ b/packages/ai/nina/policy/capability.ts @@ -0,0 +1,93 @@ +import { + EvidenceEnvelope, + type LearningCapabilityName, + LearningCapabilityResult, + MATH_CAPABILITY, + NAKAFA_CAPABILITY, + RESEARCH_CAPABILITY, +} from "@repo/ai/nina/capability/spec"; +import type { AgentContext } from "@repo/ai/types/agents"; +import { Schema } from "effect"; + +/** Schema-owned permission result for a Nina capability in one turn. */ +export const NinaCapabilityDecisionSchema = Schema.Struct({ + reason: Schema.optional(Schema.String), + state: Schema.Literal("allowed", "denied", "needs-confirmation"), +}).pipe(Schema.mutable); + +export type NinaCapabilityDecision = Schema.Schema.Type< + typeof NinaCapabilityDecisionSchema +>; + +/** Resolves whether one Nina capability may run under the immutable context pack. */ +export function decideNinaCapability({ + capability, + context, +}: { + readonly capability: LearningCapabilityName; + readonly context: AgentContext; +}): NinaCapabilityDecision { + const tools = context.nina?.tools; + + if (!tools) { + return { + state: "denied", + reason: "Nina context is unavailable for this turn.", + }; + } + + if (capability === NAKAFA_CAPABILITY && !tools.allowNakafa) { + return { + state: "denied", + reason: "Nakafa evidence is not allowed for this context.", + }; + } + + if (capability === RESEARCH_CAPABILITY && !tools.allowDeepResearch) { + return { + state: "denied", + reason: "External research is not allowed for this context.", + }; + } + + if (capability === MATH_CAPABILITY && !tools.allowMath) { + return { + state: "denied", + reason: "Math verification is not allowed for this context.", + }; + } + + return { state: "allowed" }; +} + +/** Builds model-facing evidence for a capability denied by turn policy. */ +export function deniedCapabilityResult({ + capability, + decision, +}: { + readonly capability: LearningCapabilityName; + readonly decision: NinaCapabilityDecision; +}) { + const text = [ + "# Capability Policy", + "", + `- Capability: ${capability}`, + "- Status: denied", + `- Reason: ${decision.reason ?? "Not allowed for this Nina context."}`, + "", + "# Final Answer Constraint", + "", + "Use only evidence already available in the conversation.", + "Do not invent unavailable Nakafa, research, or math evidence.", + ].join("\n"); + + return LearningCapabilityResult.make({ + evidence: EvidenceEnvelope.make({ + capability, + limitations: [decision.reason ?? "Not allowed for this Nina context."], + status: "denied", + summary: text, + }), + text, + }); +} diff --git a/packages/ai/nina/policy/tool.ts b/packages/ai/nina/policy/tool.ts new file mode 100644 index 0000000000..fc0f0711d6 --- /dev/null +++ b/packages/ai/nina/policy/tool.ts @@ -0,0 +1,138 @@ +/** Formats Nina's tool routing, evidence, and specialist input policy. */ +export function formatToolPolicyPrompt() { + return ` + # Tool Usage Guidelines + + Use the smallest reliable evidence path before the final answer: + - Nakafa: Nakafa-owned lessons, articles, Quran references, and exercises. + - deepResearch: external, current, official, or source-backed claims. + - math: deterministic calculations, formulas, answer keys, and verification. + + Answer directly only for low-risk requests that need no source, current fact, or math check: + - greetings. + - preferences. + - simple rewrites. + + ## Specialist Input Contract + + All specialist tools share compact fields: + - request: task-relevant user details only. + - objective: the specialist job only. + - requirements: real retrieval or verification constraints only; omit when none exist. + + request must: + - keep connective wording in the user's language. + - preserve technical names and terms exactly. + - when the user writes in a non-English language, keep the cleaned request in that same language. + - preserve names, dates, URLs, domains, versions, source owners, formulas, values, variables, matrices, data, level, context, and requested deliverables. + - omit unrelated text, repetition, emotional phrasing, and tool or prompt noise. + - avoid copying the full user message when only part is relevant. + + Specialist-specific fields: + - deepResearch.sourceRequirements: source ownership, recency, domain, URL, and credibility. + - nakafa.deliverables: lessons, summaries, examples, exercises, answers, Quran references, or article needs. + - math.given: user-provided expressions, equations, variables, assumptions, matrices, data, selected exercise content, or answer keys. + + Tool inputs must not include persona rules, global formatting rules, fallback answer wording, or outcome-dependent instructions. + + ## Routing Standard + + Decide from the user's request and gathered evidence, not from content slugs, material names, section labels, or UI labels alone. + Every factual claim needs the right evidence: + - Nakafa evidence for Nakafa-owned content. + - Source-backed research evidence for external or current claims. + - Math evidence for calculations, formulas, numeric answers, answer keys, equivalence checks, probability, statistics, matrix properties, geometry, and discrete counting. + + If evidence is missing, call the matching specialist. + If evidence still cannot be gathered, answer with the limitation instead of guessing. + + ## Nakafa + + Use Nakafa first for named educational topics, lesson explanations, study requests, current verified page content, and educational practice. + Practice includes warmups, starter examples, hints, quick reviews, quizzes, tryout preparation, and preparation before practice. + + Nakafa routing rules: + - Preserve every requested deliverable. + - Include helpful retrieval context: URL, verified status, user goal, subject, grade, topic, article, exercise, or Quran context. + - Search first when the exact content reference is not known. + - Do not add a lesson or concept overview unless the user asks for one. + - For warmups or starter examples followed by practice, ask Nakafa for exercise evidence only; Nina can write a short setup from that evidence. + - If the user asks for explanation plus practice, include both needs. + - If the user only asks for practice, scope Nakafa to exercise retrieval and explanation. + + Do not use Nakafa to fill missing evidence for external, current, official-source, or source-owned verification questions. + Use Nakafa after weak external research only for a separate user-requested Nakafa deliverable: + - lessons. + - exercises. + - Quran content. + - articles. + - current verified page content. + - practice. + + ## deepResearch + + Use deepResearch before answering requests for: + - official documentation. + - source-backed claims. + - citations. + - external links. + - current or latest information. + - named products outside Nakafa. + This applies in every user language. Do not answer those requests from memory. + + Preserve source-scoping details in request or sourceRequirements: + - Products, APIs, libraries, features, versions, domains, URLs, source constraints, and document titles. + - Official, primary, maintainer, vendor, standards-body, paper-author, or named-domain requirements. + + Keep research inputs neutral when sources may be missing or weak: + - Ask for direct-source verification. + - Do not prewrite failed-verification wording. + - Do not tell deepResearch to say something was not found. + + ## math + + Use math for deterministic evidence across arithmetic, algebra, equations, inequalities, calculus, series, matrices, statistics, probability, geometry, and discrete math. + Use math to verify user-provided expressions, user-provided data, and math content retrieved from another evidence path. + + Do not use math as the first or only source for practice sets: warmups, quizzes, tryout preparation, examples, hints, or review tasks. + Call Nakafa first, then use math only for deterministic verification of selected content. + + Math input rules: + - Include the complete expression or data, target operation, variables, and learning goal when relevant. + - Put only user-provided or retrieved math facts in given; do not preload solution methods or derived formulas. + - For extrema, minimum, or maximum requests, ask math for the valid location and function value unless the user asks only for the input location. + - Preserve derivation, proof, and "why" deliverables so math returns the checked value plus the conceptual bridge. + - For multi-part requests, enumerate each calculation or verification. + - Do not collapse several computations into a vague objective such as "verify these calculations". + - If deterministic math is inconclusive, explain the limitation clearly. + + ## Combining Agents + + Use more than one specialist when the answer needs more than one evidence type. + Call independent specialists in parallel in the same step. + + For educational practice: + - Nakafa selects content. + - math verifies selected calculations. + - Never create practice content inside the math input. + - Include the exact example, exercise, answer key, and numeric claims that will appear in the final answer. + - Do not switch to different math content after verification. + + Use deepResearch for current, external, or source-backed information beyond Nakafa. + Use math after deepResearch when researched numbers or claims need calculation, comparison, statistics, or verification. + + Never invent source-specific content, current facts, exercise choices, citations, or verified math without relevant evidence. + After weak or missing deepResearch evidence for an external, current, official, or source-owned claim: + - Do not switch to generic Nakafa search just to provide something. + - Use another evidence path only when it satisfies the same source constraint or a separate user-requested learning/practice deliverable. + + Preserve source constraints in the final answer: + - Keep one product, domain, document, or official source scoped to the requested source. + - Do not add adjacent frameworks or generic alternatives unless the user asks for comparisons. + + If a specialist returns an error: + - Do not call the same specialist again with the same request. + - Use a different evidence path only when it can add new evidence. + - Otherwise answer with a clear limitation. + `; +} diff --git a/packages/ai/nina/prompt/examples.ts b/packages/ai/nina/prompt/examples.ts new file mode 100644 index 0000000000..3841f5273a --- /dev/null +++ b/packages/ai/nina/prompt/examples.ts @@ -0,0 +1,31 @@ +/** Formats compact positive and negative specialist input examples. */ +export function formatExamplesPrompt() { + return ` + # Specialist Input Examples + + Good Nakafa input: + - request: cleaned user-language topic and practice scope. + - objective: find suitable Nakafa-owned exercise or lesson evidence. + - requirements: real content scope only. + - deliverables: requested Nakafa content pieces. + + Good math follow-up input: + - request: cleaned user-language math verification request. + - objective: check selected calculations, answer keys, and numeric claims. + - requirements: use only the selected evidence when verifying retrieved content. + - given: user-provided or retrieved expressions, data, assumptions, and answer keys. + + Good deepResearch input: + - request: cleaned user-language source-specific research request. + - objective: find direct source-backed evidence for the requested claim. + - sourceRequirements: named owners, domains, URLs, recency, or credibility constraints from the user. + + Bad specialist inputs: + - request: "Find something about math." Problem: vague and missing the specialist objective. + - request: "Use math because the page path has mathematics." Problem: routes from metadata instead of the actual request and evidence. + - request: translated user wording. Problem: loses the user's language and exact technical terms. + - objective: "Search everything and answer." Problem: mixes retrieval, verification, and final response. + - requirements: global output formatting rules. Problem: repeats answer-formatting instead of task constraints. + - requirements: scripted failed-outcome wording. Problem: scripts an outcome before evidence exists. + `; +} diff --git a/packages/ai/nina/prompt/format.ts b/packages/ai/nina/prompt/format.ts new file mode 100644 index 0000000000..666ed82255 --- /dev/null +++ b/packages/ai/nina/prompt/format.ts @@ -0,0 +1,80 @@ +/** Formats final-answer markdown, math, link, and list requirements. */ +export function formatAnswerPrompt() { + return ` + # Output Formatting Guidelines + + Use markdown only. Do not use HTML, XML, or other markup. + Never mention AI, tools, functions, prompts, or internal processes to users. + + ## Limitation-only research answers + + If research returns a single limitation sentence with no source-backed findings: + - Use that sentence as the full answer for the verification part. + - Do not paraphrase, decorate, or turn it into a search-result summary. + - Do not say information, evidence, proof, announcements, or sources were found or not found. + + ## Mathematical format + + Use LaTeX for numbers, variables, and expressions. + - Inline math: \\(...\\). + - Block math: \\[...\\]; use \\\\ for line breaks. + - Text inside math: use \\text{...}. + - Rewrite retrieved $...$ or $$...$$ math to \\(...\\) or \\[...\\]. + - Never use dollar delimiters or inline code for math. + + ## Code block format + + Use \`\`\`{language} for code blocks. + Add code comments only when necessary. + - Never use code blocks for mathematical content. + - Inline code: use \`...\`. + - Never use inline code for mathematical content. + + ## Diagrams + + Use \`\`\`mermaid title="..." description="..." for helpful flowcharts, graphs, and timelines. + The title and description are required, must match the response language, and must not repeat each other. + Inside Mermaid labels, use quoted Mermaid math syntax like "$$CO_2$$"; do not use Markdown math delimiters like \\(CO_2\\). + + ## Links + + Use concise descriptive [text](url) links. + When research results contain URLs, format them as [domain](url) links. + Cite external research sources inline in the exact sentence they support. + Use only links already present in external research evidence or current page context. + Preserve research markdown links for every claim that uses that evidence. + Preserve source-backed technical details exactly: + - Framework configuration. + - CLI commands. + - API names. + - Version numbers. + - Code shapes. + + Do not add product homepages, documentation links, parent objects, flags, wrappers, options, or source links from memory. + Each source-backed section or bullet must keep at least one supporting link. + Do not add Nakafa source labels, Nakafa domain links, or citation-style links for Nakafa-owned content. + Convert any research citation indexes into markdown links using the cited source URLs. + Never show numeric citation markers or append a source/reference/bibliography section. + + ## Lists + + Use short paragraphs for explanation and lists for clear distinctions. + Use 1., 2., 3. for ordered steps and - for unordered items. + Keep lists brief and indentation clean. + When a list item contains continuation text, block math, a diagram, or a code block, indent that child content under the list item instead of restarting at the page margin. + Multiple-choice options MUST be formatted as one markdown bullet per option: + - A. Option text + - B. Option text + - C. Option text + - D. Option text + - E. Option text + Never write multiple-choice options inline in one paragraph. + Never rely on raw line breaks without bullet markers for multiple-choice options. + + ## Headings + + Use ## (h2) or ### (h3) for headings. + Keep headings short and descriptive. + Never use # (h1), numbered headings, or decorative punctuation in headings. + `; +} diff --git a/packages/ai/nina/prompt/persona.ts b/packages/ai/nina/prompt/persona.ts new file mode 100644 index 0000000000..4ee9af9f91 --- /dev/null +++ b/packages/ai/nina/prompt/persona.ts @@ -0,0 +1,76 @@ +import type { PromptUserRole } from "@repo/ai/types/roles"; +import dedent from "dedent"; + +/** Formats Nina's identity instructions with role-specific guidance. */ +export function formatIdentityPrompt(userRole: PromptUserRole | undefined) { + return ` + # Identity + + Your name is Nina. + You are Nakafa's education assistant for K-12 through university learners. + If prior context makes the user's goal clear, proactively take the necessary action. + Be friendly, direct, source-grounded, concise, and age-appropriate. + Stay in character as a real human assistant. + + ${formatUserRoleContext(userRole)} + `; +} + +/** Formats Nina's stable communication style instructions. */ +export function formatTonePrompt() { + return ` + # Communication Style + + Be casual, warm, concise, and clear. + Use simple words, everyday analogies, short sentences, and small steps. + Correct mistakes clearly without shaming the user. + Always use the user's language. Never mix languages. + Use emojis only when they genuinely help the tone. + `; +} + +/** Builds user-role-specific behavior context without changing tool contracts. */ +function formatUserRoleContext(userRole: PromptUserRole | undefined) { + switch (userRole) { + case "teacher": + return dedent(` + **User is a teacher.** + + Support lesson planning, materials, assessment, pedagogy, classroom practice, and education research. + Be professional, efficient, and practical. + `); + + case "student": + return dedent(` + **User is a student.** + + Help the student understand, practice, and solve problems. + Use simple language, small steps, examples, and level-appropriate guidance. + Be patient, supportive, and focused on independent understanding. + `); + + case "parent": + return dedent(` + **User is a parent.** + + Help parents understand school topics, homework, study support, assessment, and school systems. + Be empathetic, clear, and practical. + `); + + case "administrator": + return dedent(` + **User is an administrator (school or organization).** + + Support policy, planning, reporting, standards, stakeholder communication, and operational decisions. + Be professional, analytical, and evidence-based. + `); + + default: + return dedent(` + **User identity is unknown.** + + Treat the user as a curious learner. + Be welcoming, clear, patient, and focused on their stated goal. + `); + } +} diff --git a/packages/ai/agents/orchestrator/prompt.test.ts b/packages/ai/nina/prompt/prompt.test.ts similarity index 80% rename from packages/ai/agents/orchestrator/prompt.test.ts rename to packages/ai/nina/prompt/prompt.test.ts index f9fde86ed3..d34c612461 100644 --- a/packages/ai/agents/orchestrator/prompt.test.ts +++ b/packages/ai/nina/prompt/prompt.test.ts @@ -1,6 +1,57 @@ -import { nakafaPrompt } from "@repo/ai/agents/orchestrator/prompt"; +import type { NinaContextPack } from "@repo/ai/nina/memory/pack"; +import { createNinaPrompt } from "@repo/ai/nina/prompt/prompt"; +import { LearningProgramKeySchema } from "@repo/contents/_types/program/schema"; import { describe, expect, it } from "vitest"; +const nina = { + learning: { + assetId: "asset:id:material:mathematics:integral:riemann-sum", + contentId: "asset:id:material:mathematics:integral:riemann-sum", + locale: "id", + materialKey: "mathematics", + section: "subject-lesson", + slug: "materi/matematika/integral/jumlahan-riemann", + sourcePath: "material/lesson/mathematics/integral/riemann-sum", + title: "Jumlahan Riemann", + url: "https://nakafa.com/id/materi/matematika/integral/jumlahan-riemann", + verified: true, + }, + snapshot: { + capturedAt: "2026-05-09T00:00:00.000Z", + learning: { + assetId: "asset:id:material:mathematics:integral:riemann-sum", + contentId: "asset:id:material:mathematics:integral:riemann-sum", + locale: "id", + materialKey: "mathematics", + section: "subject-lesson", + slug: "materi/matematika/integral/jumlahan-riemann", + sourcePath: "material/lesson/mathematics/integral/riemann-sum", + title: "Jumlahan Riemann", + url: "https://nakafa.com/id/materi/matematika/integral/jumlahan-riemann", + verified: true, + }, + source: "current-page", + tools: { + allowDeepResearch: true, + allowMath: true, + allowNakafa: true, + allowPageFetch: true, + evidenceScope: "verified-page", + }, + }, + tools: { + allowDeepResearch: true, + allowMath: true, + allowNakafa: true, + allowPageFetch: true, + evidenceScope: "verified-page", + }, + transition: { + reason: "page-context", + toContextKey: "canonical:materi/matematika/integral/jumlahan-riemann", + }, +} satisfies NinaContextPack; + const base = { currentDate: "May 9, 2026", currentPage: { @@ -16,11 +67,12 @@ const base = { latitude: "52.52", longitude: "13.405", }, + nina, } as const; -describe("nakafaPrompt", () => { +describe("createNinaPrompt", () => { it("keeps prompt responsibilities in clean sections", () => { - const prompt = nakafaPrompt({ + const prompt = createNinaPrompt({ ...base, userRole: "student", }); @@ -58,7 +110,7 @@ describe("nakafaPrompt", () => { }); it("keeps one stable Nina persona without conflicting harsh-advisor rules", () => { - const prompt = nakafaPrompt({ + const prompt = createNinaPrompt({ ...base, userRole: "student", }); @@ -72,7 +124,7 @@ describe("nakafaPrompt", () => { }); it("defines compact specialist inputs without the old task blob", () => { - const prompt = nakafaPrompt(base); + const prompt = createNinaPrompt(base); const toolSection = prompt.slice( prompt.indexOf("# Tool Usage Guidelines"), prompt.indexOf("# Task Instructions") @@ -107,7 +159,7 @@ describe("nakafaPrompt", () => { }); it("routes evidence through the right specialist before final claims", () => { - const prompt = nakafaPrompt(base); + const prompt = createNinaPrompt(base); expect(prompt).toContain( "Use the smallest reliable evidence path before the final answer" @@ -129,7 +181,7 @@ describe("nakafaPrompt", () => { }); it("keeps Nakafa practice selection separate from math verification", () => { - const prompt = nakafaPrompt(base); + const prompt = createNinaPrompt(base); expect(prompt).toContain("Practice includes warmups"); expect(prompt).toContain( @@ -149,7 +201,7 @@ describe("nakafaPrompt", () => { }); it("keeps failed external research from becoming generic Nakafa fallback", () => { - const prompt = nakafaPrompt(base); + const prompt = createNinaPrompt(base); expect(prompt).toContain( "Do not use Nakafa to fill missing evidence for external, current, official-source, or source-owned verification questions." @@ -166,7 +218,7 @@ describe("nakafaPrompt", () => { }); it("keeps final answer formatting explicit but compact", () => { - const prompt = nakafaPrompt(base); + const prompt = createNinaPrompt(base); expect(prompt).toContain("Always use the user's language."); expect(prompt).toContain( @@ -200,7 +252,7 @@ describe("nakafaPrompt", () => { "administrator", ] as const)("includes compact role guidance for %s", (userRole) => { expect( - nakafaPrompt({ + createNinaPrompt({ ...base, userRole, }) @@ -208,7 +260,7 @@ describe("nakafaPrompt", () => { }); it("includes selected learning program context without table-shaped prose", () => { - const prompt = nakafaPrompt({ + const prompt = createNinaPrompt({ ...base, learningProfile: { interests: ["exam-prep", "assessment-prep"], @@ -225,7 +277,7 @@ describe("nakafaPrompt", () => { ], program: { coverageStatus: "partial", - key: "snbt-2026", + key: LearningProgramKeySchema.make("snbt-2026"), kind: "admission-exam", title: "SNBT 2026", versionLabel: "2026", @@ -247,7 +299,7 @@ describe("nakafaPrompt", () => { }); it("includes default role guidance and unverified page context", () => { - const prompt = nakafaPrompt({ + const prompt = createNinaPrompt({ ...base, currentPage: { ...base.currentPage, diff --git a/packages/ai/nina/prompt/prompt.ts b/packages/ai/nina/prompt/prompt.ts new file mode 100644 index 0000000000..93a2ac9790 --- /dev/null +++ b/packages/ai/nina/prompt/prompt.ts @@ -0,0 +1,38 @@ +import { formatToolPolicyPrompt } from "@repo/ai/nina/policy/tool"; +import { formatExamplesPrompt } from "@repo/ai/nina/prompt/examples"; +import { formatAnswerPrompt } from "@repo/ai/nina/prompt/format"; +import { + formatIdentityPrompt, + formatTonePrompt, +} from "@repo/ai/nina/prompt/persona"; +import { + formatRuntimePrompt, + RuntimePromptContextSchema, +} from "@repo/ai/nina/prompt/runtime"; +import { formatTaskPrompt } from "@repo/ai/nina/prompt/task"; +import { createPrompt } from "@repo/ai/prompt/utils"; +import { PromptUserRoleSchema } from "@repo/ai/types/roles"; +import { Schema } from "effect"; + +/** Runtime context plus authenticated role used to build Nina's system prompt. */ +const SystemPromptPropsSchema = Schema.extend( + RuntimePromptContextSchema, + Schema.Struct({ + userRole: Schema.optional(PromptUserRoleSchema), + }) +); + +type SystemPromptProps = Schema.Schema.Type; + +/** Builds Nina's system prompt with internal LearningCapability policy. */ +export function createNinaPrompt({ userRole, ...runtime }: SystemPromptProps) { + return createPrompt({ + taskContext: formatIdentityPrompt(userRole), + toneContext: formatTonePrompt(), + backgroundData: formatRuntimePrompt(runtime), + toolUsageGuidelines: formatToolPolicyPrompt(), + detailedTaskInstructions: formatTaskPrompt(), + examples: formatExamplesPrompt(), + outputFormatting: formatAnswerPrompt(), + }); +} diff --git a/packages/ai/nina/prompt/runtime.ts b/packages/ai/nina/prompt/runtime.ts new file mode 100644 index 0000000000..ea1f34dd31 --- /dev/null +++ b/packages/ai/nina/prompt/runtime.ts @@ -0,0 +1,62 @@ +import { NinaContextPackSchema } from "@repo/ai/nina/memory/pack"; +import { formatNinaContextPackPrompt } from "@repo/ai/nina/prompt/system"; +import { formatLearningProfilePromptContext } from "@repo/ai/prompt/learning-profile"; +import { AgentLearningProfileSchema } from "@repo/ai/types/agents"; +import { LocaleSchema } from "@repo/contents/_types/content"; +import { Schema } from "effect"; + +/** Structured runtime facts that Nina can use without route or title guessing. */ +export const RuntimePromptContextSchema = Schema.Struct({ + currentDate: Schema.String, + currentPage: Schema.Struct({ + locale: LocaleSchema, + slug: Schema.String, + verified: Schema.Boolean, + }).pipe(Schema.mutable), + learningProfile: Schema.optional(AgentLearningProfileSchema), + nina: NinaContextPackSchema, + url: Schema.String, + userLocation: Schema.Struct({ + city: Schema.String, + country: Schema.String, + countryRegion: Schema.String, + latitude: Schema.String, + longitude: Schema.String, + }).pipe(Schema.mutable), +}).pipe(Schema.mutable); + +export type RuntimePromptContext = Schema.Schema.Type< + typeof RuntimePromptContextSchema +>; + +/** Formats verified page, location, Nina context, and learning profile facts. */ +export function formatRuntimePrompt({ + currentDate, + currentPage, + learningProfile, + nina, + url, + userLocation, +}: RuntimePromptContext) { + return ` + # Runtime Context + + Current page: + - url: ${url} + - locale: ${currentPage.locale} + - slug: ${currentPage.slug} + - verified: ${currentPage.verified ? "yes" : "no"} + + User context: + - date: ${currentDate} + - city: ${userLocation.city} + - country: ${userLocation.country} + - country region: ${userLocation.countryRegion} + - latitude: ${userLocation.latitude} + - longitude: ${userLocation.longitude} + + ${formatNinaContextPackPrompt(nina)} + + ${formatLearningProfilePromptContext(learningProfile)} + `; +} diff --git a/packages/ai/nina/prompt/system.test.ts b/packages/ai/nina/prompt/system.test.ts new file mode 100644 index 0000000000..5db5b2527b --- /dev/null +++ b/packages/ai/nina/prompt/system.test.ts @@ -0,0 +1,133 @@ +import type { NinaContextPack } from "@repo/ai/nina/memory/pack"; +import { formatNinaContextPackPrompt } from "@repo/ai/nina/prompt/system"; +import { LearningProgramKeySchema } from "@repo/contents/_types/program/schema"; +import { describe, expect, it } from "vitest"; + +const placementProgramKey = LearningProgramKeySchema.make( + "cambridge-lower-secondary" +); + +const canonicalContext = { + learning: { + locale: "en", + slug: "chat", + url: "https://nakafa.com/en/chat", + verified: false, + }, + snapshot: { + capturedAt: "2026-05-09T00:00:00.000Z", + learning: { + locale: "en", + slug: "chat", + url: "https://nakafa.com/en/chat", + verified: false, + }, + source: "current-page", + tools: { + allowDeepResearch: false, + allowMath: false, + allowNakafa: false, + allowPageFetch: false, + evidenceScope: "general-learning", + }, + }, + tools: { + allowDeepResearch: false, + allowMath: false, + allowNakafa: false, + allowPageFetch: false, + evidenceScope: "general-learning", + }, + transition: { + reason: "page-context", + toContextKey: "canonical:chat", + }, +} satisfies NinaContextPack; + +const placementContext = { + learning: { + assetId: "asset:id:material:mathematics:vector:addition", + contentId: "asset:id:material:mathematics:vector:addition", + locale: "en", + materialKey: "mathematics", + section: "subject-lesson", + slug: "subjects/mathematics/vector/addition", + sourcePath: "material/lesson/mathematics/vector/addition", + title: "Vector Addition", + url: "https://nakafa.com/en/subjects/mathematics/vector/addition", + verified: true, + }, + placement: { + mode: "placement", + nodeKey: "curriculum:vector:addition", + parentHref: "/en/curriculum/mathematics/vector", + parentTitle: "Vector", + programKey: placementProgramKey, + }, + snapshot: { + capturedAt: "2026-05-09T00:00:00.000Z", + learning: { + assetId: "asset:id:material:mathematics:vector:addition", + contentId: "asset:id:material:mathematics:vector:addition", + locale: "en", + materialKey: "mathematics", + section: "subject-lesson", + slug: "subjects/mathematics/vector/addition", + sourcePath: "material/lesson/mathematics/vector/addition", + title: "Vector Addition", + url: "https://nakafa.com/en/subjects/mathematics/vector/addition", + verified: true, + }, + placement: { + mode: "placement", + nodeKey: "curriculum:vector:addition", + parentHref: "/en/curriculum/mathematics/vector", + parentTitle: "Vector", + programKey: placementProgramKey, + }, + source: "current-page", + tools: { + allowDeepResearch: true, + allowMath: true, + allowNakafa: true, + allowPageFetch: true, + evidenceScope: "verified-page", + }, + }, + tools: { + allowDeepResearch: true, + allowMath: true, + allowNakafa: true, + allowPageFetch: true, + evidenceScope: "verified-page", + }, + transition: { + reason: "page-context", + toContextKey: + "placement:cambridge-lower-secondary:curriculum:vector:addition:subjects/mathematics/vector/addition", + }, +} satisfies NinaContextPack; + +describe("formatNinaContextPackPrompt", () => { + it("formats canonical context without inventing asset or placement details", () => { + const prompt = formatNinaContextPackPrompt(canonicalContext); + + expect(prompt).toContain("- verified: no"); + expect(prompt).toContain("- title: unknown"); + expect(prompt).toContain("- sourcePath: unknown"); + expect(prompt).toContain("Placement: canonical direct asset visit"); + expect(prompt).toContain("- Nakafa evidence allowed: no"); + expect(prompt).toContain("- evidence scope: general-learning"); + }); + + it("formats verified placement context and allowed capability policy", () => { + const prompt = formatNinaContextPackPrompt(placementContext); + + expect(prompt).toContain("- verified: yes"); + expect(prompt).toContain("- title: Vector Addition"); + expect(prompt).toContain("- mode: placement"); + expect(prompt).toContain("- parentTitle: Vector"); + expect(prompt).toContain("- current page fetch allowed: yes"); + expect(prompt).toContain("- evidence scope: verified-page"); + }); +}); diff --git a/packages/ai/nina/prompt/system.ts b/packages/ai/nina/prompt/system.ts new file mode 100644 index 0000000000..241716cc20 --- /dev/null +++ b/packages/ai/nina/prompt/system.ts @@ -0,0 +1,74 @@ +import type { + NinaPage, + NinaRuntime, + NinaUser, +} from "@repo/ai/nina/contract/turn"; +import { readNinaLearningPage } from "@repo/ai/nina/contract/turn"; +import type { NinaContextPack } from "@repo/ai/nina/memory/pack"; +import { createNinaPrompt } from "@repo/ai/nina/prompt/prompt"; +import dedent from "dedent"; + +/** Formats Nina's validated context pack for the system prompt. */ +export function formatNinaContextPackPrompt(context: NinaContextPack) { + const placement = context.placement + ? dedent` + Placement: + - mode: placement + - programKey: ${context.placement.programKey} + - nodeKey: ${context.placement.nodeKey} + - parentHref: ${context.placement.parentHref} + - parentTitle: ${context.placement.parentTitle} + ` + : "Placement: canonical direct asset visit"; + + return dedent` + # Nina Context Pack + + Learning asset: + - url: ${context.learning.url} + - locale: ${context.learning.locale} + - slug: ${context.learning.slug} + - verified: ${context.learning.verified ? "yes" : "no"} + - title: ${context.learning.title ?? "unknown"} + - sourcePath: ${context.learning.sourcePath ?? "unknown"} + - assetId: ${context.learning.assetId ?? "unknown"} + - section: ${context.learning.section ?? "unknown"} + - materialKey: ${context.learning.materialKey ?? "unknown"} + + ${placement} + + Tool policy: + - Nakafa evidence allowed: ${context.tools.allowNakafa ? "yes" : "no"} + - current page fetch allowed: ${context.tools.allowPageFetch ? "yes" : "no"} + - math evidence allowed: ${context.tools.allowMath ? "yes" : "no"} + - deep research allowed: ${context.tools.allowDeepResearch ? "yes" : "no"} + - evidence scope: ${context.tools.evidenceScope} + `; +} + +/** Builds Nina's system prompt from validated runtime, page, and user context. */ +export function createNinaSystemPrompt({ + page, + runtime, + user, +}: { + readonly page: NinaPage; + readonly runtime: NinaRuntime; + readonly user: NinaUser; +}) { + const learningPage = readNinaLearningPage(page); + + return createNinaPrompt({ + currentDate: runtime.currentDate, + currentPage: { + locale: learningPage.locale, + slug: learningPage.slug, + verified: learningPage.verified, + }, + learningProfile: user.learningProfile, + nina: page.nina, + url: learningPage.url, + userLocation: user.location, + userRole: user.role, + }); +} diff --git a/packages/ai/nina/prompt/task.ts b/packages/ai/nina/prompt/task.ts new file mode 100644 index 0000000000..22d4f2386d --- /dev/null +++ b/packages/ai/nina/prompt/task.ts @@ -0,0 +1,21 @@ +/** Formats Nina's ordered task execution and limitation policy. */ +export function formatTaskPrompt() { + return ` + # Task Instructions + + Work in order: + 1. Understand the user's goal. + 2. Choose the smallest reliable evidence path. + 3. Use retrieved evidence before answering source-specific, current, or mathematical claims. + 4. Answer in the user's language with clear markdown. + + For external, current, official, or source-owned questions, source-backed research is the answer gate. + If research returns no source-backed finding: + - Use the research limitation as the answer for that verification part. + - Keep it as a process limitation, not a claim that sources, announcements, public information, or confirmations do not exist. + - Do not add greetings, advice, encouragement, unrelated Nakafa content, or extra bullets around a limitation-only answer. + - If the user also asks for study help or practice, separate that deliverable from the verification answer. + + Keep visible reasoning brief. Do not write long plans unless the user asks for one. + `; +} diff --git a/packages/ai/nina/runtime/agent.test.ts b/packages/ai/nina/runtime/agent.test.ts new file mode 100644 index 0000000000..76a6576964 --- /dev/null +++ b/packages/ai/nina/runtime/agent.test.ts @@ -0,0 +1,494 @@ +import { ModelIdSchema } from "@repo/ai/config/model"; +import { + createNinaAgentContext, + type NinaPage, + type NinaRuntime, + type NinaUser, +} from "@repo/ai/nina/contract/turn"; +import type { NinaContextPack } from "@repo/ai/nina/memory/pack"; +import { NinaAgentError, runNinaAgentTurn } from "@repo/ai/nina/runtime/agent"; +import { + nakafaToolInputSchema, + researchToolInputSchema, + textOutputSchema, +} from "@repo/ai/schema/tools"; +import type { MyMetadata, MyUIMessage } from "@repo/ai/types/message"; +import { LearningProgramKeySchema } from "@repo/contents/_types/program/schema"; +import type { + LanguageModelUsage, + ModelMessage, + ToolSet, + UIMessageStreamWriter, +} from "ai"; +import { tool } from "ai"; +import { Cause, Effect, Exit, Option } from "effect"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +interface CapturedAgentSettings { + readonly id?: string; + readonly instructions?: string; + readonly prepareStep?: (input: { + readonly messages: ModelMessage[]; + readonly stepNumber: number; + }) => unknown; + readonly stopWhen?: unknown; + readonly tools?: ToolSet; +} + +interface CapturedStreamOptions { + readonly messages: ModelMessage[]; + readonly timeout?: unknown; +} + +interface CapturedMessageStreamOptions { + readonly messageMetadata?: (input: { + readonly part: + | { readonly type: "start" } + | { readonly type: "text-delta" } + | { readonly type: "finish" } + | { readonly totalUsage: LanguageModelUsage; readonly type: "finish" }; + }) => MyMetadata | undefined; + readonly onError?: (error: unknown) => string; +} + +interface FakeAgentState { + deltaMetadata?: MyMetadata; + emptyFinishMetadata?: MyMetadata; + finishMetadata?: MyMetadata; + responseFailure?: Error; + settings?: CapturedAgentSettings; + startMetadata?: MyMetadata; + streamErrorMessage?: string; + streamFailure?: Error; + streamOptions?: CapturedStreamOptions; +} + +const fakeAgentState = vi.hoisted((): FakeAgentState => ({})); + +/** Returns a complete AI SDK usage object for metadata callbacks. */ +function createUsage(): LanguageModelUsage { + return { + cachedInputTokens: undefined, + inputTokenDetails: { + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + noCacheTokens: 4, + }, + inputTokens: 4, + outputTokenDetails: { + reasoningTokens: undefined, + textTokens: 6, + }, + outputTokens: 6, + raw: undefined, + reasoningTokens: undefined, + totalTokens: 10, + }; +} + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal(); + + /** Captures ToolLoopAgent settings and returns a deterministic stream result. */ + class FakeToolLoopAgent { + constructor(settings: CapturedAgentSettings) { + fakeAgentState.settings = settings; + } + + /** Streams a single assistant response without contacting a provider. */ + stream(options: CapturedStreamOptions) { + if (fakeAgentState.streamFailure) { + return Promise.reject(fakeAgentState.streamFailure); + } + + fakeAgentState.streamOptions = options; + fakeAgentState.settings?.prepareStep?.({ + messages: options.messages, + stepNumber: 0, + }); + + return Promise.resolve({ + response: fakeAgentState.responseFailure + ? Promise.reject(fakeAgentState.responseFailure) + : Promise.resolve({ + messages: [ + { + content: [{ text: "Ready.", type: "text" }], + role: "assistant", + }, + ], + }), + /** Captures stream metadata callbacks without opening an SSE stream. */ + toUIMessageStream(streamOptions: CapturedMessageStreamOptions) { + fakeAgentState.startMetadata = streamOptions.messageMetadata?.({ + part: { type: "start" }, + }); + fakeAgentState.deltaMetadata = streamOptions.messageMetadata?.({ + part: { type: "text-delta" }, + }); + fakeAgentState.emptyFinishMetadata = streamOptions.messageMetadata?.({ + part: { type: "finish" }, + }); + fakeAgentState.finishMetadata = streamOptions.messageMetadata?.({ + part: { totalUsage: createUsage(), type: "finish" }, + }); + fakeAgentState.streamErrorMessage = streamOptions.onError?.( + new Error("stream failed") + ); + + return new ReadableStream(); + }, + }); + } + } + + return { + ...actual, + ToolLoopAgent: FakeToolLoopAgent, + }; +}); + +vi.mock("@repo/ai/config/app", () => ({ + provider: { + /** Supplies a fake model object because the mocked ToolLoopAgent never calls it. */ + languageModel: () => ({ + modelId: "test-model", + provider: "test-provider", + specificationVersion: "v2", + }), + }, +})); + +const modelId = ModelIdSchema.make("nakafa-lite"); +const placementProgramKey = LearningProgramKeySchema.make( + "cambridge-lower-secondary" +); + +const ninaContext = { + learning: { + assetId: "asset:id:material:mathematics:vector:addition", + locale: "en", + slug: "subjects/mathematics/vector/addition", + title: "Vector Addition", + url: "https://nakafa.com/en/subjects/mathematics/vector/addition", + verified: true, + }, + placement: { + mode: "placement", + nodeKey: "curriculum:vector:addition", + parentHref: "/en/curriculum/mathematics/vector", + parentTitle: "Vector", + programKey: placementProgramKey, + }, + snapshot: { + capturedAt: "2026-06-21T00:00:00.000Z", + learning: { + assetId: "asset:id:material:mathematics:vector:addition", + locale: "en", + slug: "subjects/mathematics/vector/addition", + title: "Vector Addition", + url: "https://nakafa.com/en/subjects/mathematics/vector/addition", + verified: true, + }, + placement: { + mode: "placement", + nodeKey: "curriculum:vector:addition", + parentHref: "/en/curriculum/mathematics/vector", + parentTitle: "Vector", + programKey: placementProgramKey, + }, + source: "current-page", + tools: { + allowDeepResearch: true, + allowMath: true, + allowNakafa: true, + allowPageFetch: true, + evidenceScope: "verified-page", + }, + }, + tools: { + allowDeepResearch: true, + allowMath: true, + allowNakafa: true, + allowPageFetch: true, + evidenceScope: "verified-page", + }, + transition: { + reason: "page-context", + toContextKey: + "placement:cambridge-lower-secondary:curriculum:vector:addition:subjects/mathematics/vector/addition", + }, +} satisfies NinaContextPack; + +const page = { + locale: "en", + needsFetch: true, + nina: ninaContext, + slug: "/subjects/mathematics/vector/addition", + url: "https://nakafa.com/en/subjects/mathematics/vector/addition", + verified: true, +} satisfies NinaPage; + +const runtime = { + currentDate: "June 21, 2026", + modelId, +} satisfies NinaRuntime; + +const user = { + learningProfile: undefined, + location: { + city: "Jakarta", + country: "Indonesia", + countryRegion: "Jakarta", + latitude: "-6.2", + longitude: "106.8", + }, + role: "student", +} satisfies NinaUser; + +const chat = { + finalMessages: [ + { + content: [{ text: "Explain this page.", type: "text" }], + role: "user", + }, + ], +} satisfies { readonly finalMessages: ModelMessage[] }; + +describe("nina/agent", () => { + beforeEach(() => { + fakeAgentState.deltaMetadata = undefined; + fakeAgentState.emptyFinishMetadata = undefined; + fakeAgentState.finishMetadata = undefined; + fakeAgentState.responseFailure = undefined; + fakeAgentState.settings = undefined; + fakeAgentState.startMetadata = undefined; + fakeAgentState.streamFailure = undefined; + fakeAgentState.streamErrorMessage = undefined; + fakeAgentState.streamOptions = undefined; + }); + + it("builds specialist context from validated Nina session inputs", () => { + const context = createNinaAgentContext({ page, runtime, user }); + + expect(context).toMatchObject({ + currentDate: "June 21, 2026", + needsPageFetch: true, + slug: "subjects/mathematics/vector/addition", + url: page.url, + userRole: "student", + verified: true, + }); + expect(context.nina?.snapshot).toEqual(ninaContext.snapshot); + }); + + it("uses pinned learning context when the current route is not a learning asset", () => { + const pinnedContext = { + ...ninaContext, + snapshot: { + ...ninaContext.snapshot, + source: "pinned-chat", + }, + transition: { + reason: "same-context", + toContextKey: + "placement:cambridge-lower-secondary:curriculum:vector:addition:subjects/mathematics/vector/addition", + }, + } satisfies NinaContextPack; + const context = createNinaAgentContext({ + page: { + locale: "en", + needsFetch: true, + nina: pinnedContext, + slug: "chat/abc123", + url: "https://nakafa.com/en/chat/abc123", + verified: false, + }, + runtime, + user, + }); + + expect(context).toMatchObject({ + needsPageFetch: true, + slug: "subjects/mathematics/vector/addition", + url: "https://nakafa.com/en/subjects/mathematics/vector/addition", + verified: true, + }); + expect(context.nina?.snapshot.source).toBe("pinned-chat"); + }); + + it("preserves selected learning profile context without inventing a user role", () => { + const learningProfile = { + interests: ["exam-prep"], + planItems: [], + program: { + coverageStatus: "partial", + key: LearningProgramKeySchema.make("snbt-2026"), + kind: "admission-exam", + title: "SNBT 2026", + versionLabel: "2026", + }, + } satisfies NinaUser["learningProfile"]; + const context = createNinaAgentContext({ + page, + runtime, + user: { + ...user, + learningProfile, + role: undefined, + }, + }); + + expect(context.learningProfile).toEqual(learningProfile); + expect(context.userRole).toBeUndefined(); + }); + + it("runs the ToolLoopAgent lifecycle with Nina metadata and adapter-owned tools", async () => { + const writer = { + merge: vi.fn(), + onError: undefined, + write: vi.fn(), + } satisfies UIMessageStreamWriter; + const tools = createTools(); + const onStreamError = vi.fn(); + const responseMessages = await Effect.runPromise( + runNinaAgentTurn({ + messages: chat.finalMessages, + page, + runtime, + settings: { + experimental_repairToolCall: () => + Effect.runPromise(Effect.succeed(null)), + tools, + }, + stream: { + formatError: () => "translated stream error", + onError: onStreamError, + readFinishMetadata: () => ({ + credits: 2, + model: modelId, + tokens: { input: 4, output: 6, total: 10 }, + }), + writer, + }, + user, + }) + ); + + expect(fakeAgentState.settings?.id).toBe("nina"); + expect(fakeAgentState.settings?.instructions).toContain("Vector Addition"); + expect(fakeAgentState.settings?.tools).toBe(tools); + expect(fakeAgentState.streamOptions?.messages).toEqual(chat.finalMessages); + expect(writer.merge).toHaveBeenCalledOnce(); + expect(onStreamError).toHaveBeenCalledWith( + expect.any(Error), + "toUIMessageStream" + ); + expect(fakeAgentState.streamErrorMessage).toBe("translated stream error"); + expect(fakeAgentState.startMetadata).toMatchObject({ + model: modelId, + ninaContextSnapshot: ninaContext.snapshot, + }); + expect(fakeAgentState.deltaMetadata).toBeUndefined(); + expect(fakeAgentState.emptyFinishMetadata).toBeUndefined(); + expect(fakeAgentState.finishMetadata).toMatchObject({ + model: modelId, + ninaContextTransition: ninaContext.transition, + tokens: { input: 4, output: 6, total: 10 }, + }); + expect(responseMessages).toEqual([ + { + content: [{ text: "Ready.", type: "text" }], + role: "assistant", + }, + ]); + }); + + it("keeps ToolLoopAgent stream startup failures in the Effect error channel", async () => { + fakeAgentState.streamFailure = new Error("stream startup failed"); + + const exit = await Effect.runPromiseExit( + runNinaAgentTurn({ + messages: chat.finalMessages, + page, + runtime, + settings: createSettings(), + stream: createStream(), + user, + }) + ); + + expect(Exit.isFailure(exit)).toBe(true); + expect(readExitFailure(exit)).toBeInstanceOf(NinaAgentError); + }); + + it("keeps ToolLoopAgent response failures in the Effect error channel", async () => { + fakeAgentState.responseFailure = new Error("response failed"); + + const exit = await Effect.runPromiseExit( + runNinaAgentTurn({ + messages: chat.finalMessages, + page, + runtime, + settings: createSettings(), + stream: createStream(), + user, + }) + ); + + expect(Exit.isFailure(exit)).toBe(true); + expect(readExitFailure(exit)).toBeInstanceOf(NinaAgentError); + }); +}); + +/** Extracts the typed Effect failure from an Exit for Nina agent assertions. */ +function readExitFailure(exit: Exit.Exit) { + if (Exit.isSuccess(exit)) { + return; + } + + const failure = Cause.failureOption(exit.cause); + return Option.isSome(failure) ? failure.value : undefined; +} + +/** Creates the minimal AI SDK settings needed to exercise Nina's package Module. */ +function createSettings() { + return { + experimental_repairToolCall: () => Effect.runPromise(Effect.succeed(null)), + tools: createTools(), + }; +} + +/** Creates the minimal stream callbacks needed to exercise Nina's package Module. */ +function createStream() { + const writer = { + merge: vi.fn(), + onError: undefined, + write: vi.fn(), + } satisfies UIMessageStreamWriter; + + return { + formatError: () => "translated stream error", + onError: vi.fn(), + readFinishMetadata: () => ({ + credits: 2, + model: modelId, + tokens: { input: 4, output: 6, total: 10 }, + }), + writer, + }; +} + +/** Creates the minimal AI SDK Nina tool set required by the step policy. */ +function createTools() { + return { + deepResearch: tool({ + inputSchema: researchToolInputSchema, + outputSchema: textOutputSchema, + }), + nakafa: tool({ + inputSchema: nakafaToolInputSchema, + outputSchema: textOutputSchema, + }), + }; +} diff --git a/packages/ai/nina/runtime/agent.ts b/packages/ai/nina/runtime/agent.ts new file mode 100644 index 0000000000..7c8ee51018 --- /dev/null +++ b/packages/ai/nina/runtime/agent.ts @@ -0,0 +1,170 @@ +import { provider } from "@repo/ai/config/app"; +import { getModelProviderOptions } from "@repo/ai/config/model"; +import { gatewayProviderOptions } from "@repo/ai/config/routing"; +import { chatStreamTimeout } from "@repo/ai/config/timeouts"; +import type { + NinaPage, + NinaRuntime, + NinaUser, +} from "@repo/ai/nina/contract/turn"; +import { createNinaSystemPrompt } from "@repo/ai/nina/prompt/system"; +import { + createNinaPrepareStep, + type NinaToolSet, +} from "@repo/ai/nina/runtime/step"; +import type { MyMetadata, MyUIMessage } from "@repo/ai/types/message"; +import { + type AgentStreamParameters, + type LanguageModelUsage, + type ModelMessage, + smoothStream, + stepCountIs, + ToolLoopAgent, + type ToolLoopAgentSettings, + type UIMessageStreamWriter, +} from "ai"; +import { Effect, Schema } from "effect"; + +const MAX_ORCHESTRATOR_STEPS = 20; + +/** Raised when the internal ToolLoopAgent cannot produce a Nina turn. */ +export class NinaAgentError extends Schema.TaggedError()( + "NinaAgentError", + { + message: Schema.String, + source: Schema.String, + } +) {} + +/** AI SDK-derived model-message array accepted by Nina's ToolLoopAgent stream. */ +export type NinaAgentMessages = Extract< + AgentStreamParameters, + { readonly messages: ModelMessage[] } +>["messages"]; + +type NinaAgentToolSettings = Required< + Pick, "tools"> +> & + Pick< + ToolLoopAgentSettings, + "experimental_repairToolCall" + >; + +/** Emits Nina context metadata at AI SDK stream lifecycle markers. */ +function readNinaMessageMetadata({ + page, + part, + readFinishMetadata, + runtime, +}: { + readonly page: NinaPage; + readonly part: { + readonly totalUsage?: LanguageModelUsage; + readonly type: string; + }; + readonly readFinishMetadata: (usage: LanguageModelUsage) => MyMetadata; + readonly runtime: NinaRuntime; +}): MyMetadata | undefined { + if (part.type === "start") { + return { + model: runtime.modelId, + ninaContextSnapshot: page.nina.snapshot, + ninaContextTransition: page.nina.transition, + }; + } + + if (part.type !== "finish" || !part.totalUsage) { + return; + } + + return { + ...readFinishMetadata(part.totalUsage), + ninaContextSnapshot: page.nina.snapshot, + ninaContextTransition: page.nina.transition, + }; +} + +/** Streams one Nina ToolLoopAgent turn through the internal agent lifecycle. */ +export const runNinaAgentTurn = Effect.fn("nina.agent.turn")(function* ({ + messages, + page, + runtime, + settings, + stream, + user, +}: { + readonly messages: NinaAgentMessages; + readonly page: NinaPage; + readonly runtime: NinaRuntime; + readonly settings: NinaAgentToolSettings; + readonly stream: { + readonly formatError: (error: unknown) => string; + readonly onError: (error: unknown, source: string) => void; + readonly readFinishMetadata: (usage: LanguageModelUsage) => MyMetadata; + readonly writer: UIMessageStreamWriter; + }; + readonly user: NinaUser; +}) { + const system = createNinaSystemPrompt({ page, runtime, user }); + const agent = new ToolLoopAgent({ + id: "nina", + instructions: system, + model: provider.languageModel(runtime.modelId), + prepareStep: createNinaPrepareStep({ + needsPageFetch: page.needsFetch, + system, + }), + experimental_repairToolCall: settings.experimental_repairToolCall, + providerOptions: { + gateway: gatewayProviderOptions, + google: getModelProviderOptions(runtime.modelId), + }, + stopWhen: stepCountIs(MAX_ORCHESTRATOR_STEPS), + tools: settings.tools, + }); + const streamTextResult = yield* Effect.tryPromise({ + try: () => + agent.stream({ + experimental_transform: smoothStream({ + chunking: "word", + delayInMs: 20, + }), + messages, + timeout: chatStreamTimeout, + }), + catch: () => + new NinaAgentError({ + message: "Nina ToolLoopAgent failed to start streaming.", + source: "agent.stream", + }), + }); + + stream.writer.merge( + streamTextResult.toUIMessageStream({ + messageMetadata: ({ part }) => + readNinaMessageMetadata({ + page, + part, + readFinishMetadata: stream.readFinishMetadata, + runtime, + }), + onError: (error) => { + stream.onError(error, "toUIMessageStream"); + return stream.formatError(error); + }, + sendReasoning: true, + sendStart: false, + }) + ); + + const response = yield* Effect.tryPromise({ + try: () => streamTextResult.response, + catch: () => + new NinaAgentError({ + message: "Nina ToolLoopAgent response metadata was unavailable.", + source: "agent.response", + }), + }); + + return response.messages; +}); diff --git a/packages/ai/nina/runtime/error.ts b/packages/ai/nina/runtime/error.ts new file mode 100644 index 0000000000..38c6046c45 --- /dev/null +++ b/packages/ai/nina/runtime/error.ts @@ -0,0 +1,34 @@ +import type { NinaTurn } from "@repo/ai/nina/contract/turn"; +import type { LogContext } from "@repo/utilities/logging/types"; +import { Effect } from "effect"; + +/** Formats AI SDK stream errors while preserving server-side diagnostics. */ +export function formatNinaStreamError({ + error, + logContext, + turn, +}: { + readonly error: unknown; + readonly logContext: LogContext; + readonly turn: NinaTurn; +}) { + if (error instanceof Error) { + if (error.message.includes("Rate limit")) { + Effect.runFork( + Effect.logWarning("Rate limit exceeded in Nina stream").pipe( + Effect.annotateLogs(logContext) + ) + ); + return turn.copy.rateLimitMessage; + } + + return error.message; + } + + Effect.runFork( + Effect.logError("Unknown error in Nina stream").pipe( + Effect.annotateLogs(logContext) + ) + ); + return turn.copy.errorMessage; +} diff --git a/apps/www/app/api/chat/response.test.ts b/packages/ai/nina/runtime/finish.test.ts similarity index 83% rename from apps/www/app/api/chat/response.test.ts rename to packages/ai/nina/runtime/finish.test.ts index 83c412604d..578a7ef020 100644 --- a/apps/www/app/api/chat/response.test.ts +++ b/packages/ai/nina/runtime/finish.test.ts @@ -1,10 +1,11 @@ // @vitest-environment node + +import { + getNinaResponseFailure, + IncompleteNinaResponseError, +} from "@repo/ai/nina/runtime/finish"; import type { MyUIMessage } from "@repo/ai/types/message"; import { describe, expect, it } from "vitest"; -import { - getAssistantResponseFailure, - IncompleteChatResponseError, -} from "@/app/api/chat/response"; const completeAssistantMessage = { id: "assistant-complete", @@ -12,9 +13,9 @@ const completeAssistantMessage = { parts: [{ type: "text", text: "Jawaban final.", state: "done" }], } satisfies MyUIMessage; -describe("app/api/chat/response", () => { +describe("nina/runtime/finish", () => { it("accepts an assistant response with final text", () => { - const failure = getAssistantResponseFailure({ + const failure = getNinaResponseFailure({ isAborted: false, responseMessage: completeAssistantMessage, }); @@ -23,12 +24,12 @@ describe("app/api/chat/response", () => { }); it("rejects an aborted assistant response", () => { - const failure = getAssistantResponseFailure({ + const failure = getNinaResponseFailure({ isAborted: true, responseMessage: completeAssistantMessage, }); - expect(failure).toBeInstanceOf(IncompleteChatResponseError); + expect(failure).toBeInstanceOf(IncompleteNinaResponseError); expect(failure).toMatchObject({ reason: "aborted", responseMessageId: "assistant-complete", @@ -36,7 +37,7 @@ describe("app/api/chat/response", () => { }); it("rejects a response with an open reasoning part", () => { - const failure = getAssistantResponseFailure({ + const failure = getNinaResponseFailure({ finishReason: "stop", isAborted: false, responseMessage: { @@ -49,7 +50,7 @@ describe("app/api/chat/response", () => { }, }); - expect(failure).toBeInstanceOf(IncompleteChatResponseError); + expect(failure).toBeInstanceOf(IncompleteNinaResponseError); expect(failure).toMatchObject({ finishReason: "stop", reason: "open-stream-part", @@ -58,7 +59,7 @@ describe("app/api/chat/response", () => { }); it("rejects a response with suggestions but no final text", () => { - const failure = getAssistantResponseFailure({ + const failure = getNinaResponseFailure({ isAborted: false, responseMessage: { id: "assistant-suggestions", @@ -73,7 +74,7 @@ describe("app/api/chat/response", () => { }, }); - expect(failure).toBeInstanceOf(IncompleteChatResponseError); + expect(failure).toBeInstanceOf(IncompleteNinaResponseError); expect(failure).toMatchObject({ reason: "missing-final-text", responseMessageId: "assistant-suggestions", @@ -81,7 +82,7 @@ describe("app/api/chat/response", () => { }); it("rejects a response with an open tool part", () => { - const failure = getAssistantResponseFailure({ + const failure = getNinaResponseFailure({ isAborted: false, responseMessage: { id: "assistant-open-tool", @@ -102,7 +103,7 @@ describe("app/api/chat/response", () => { }, }); - expect(failure).toBeInstanceOf(IncompleteChatResponseError); + expect(failure).toBeInstanceOf(IncompleteNinaResponseError); expect(failure).toMatchObject({ reason: "open-stream-part", responseMessageId: "assistant-open-tool", @@ -110,7 +111,7 @@ describe("app/api/chat/response", () => { }); it("rejects a response with a streaming tool input", () => { - const failure = getAssistantResponseFailure({ + const failure = getNinaResponseFailure({ isAborted: false, responseMessage: { id: "assistant-streaming-tool", @@ -131,7 +132,7 @@ describe("app/api/chat/response", () => { }, }); - expect(failure).toBeInstanceOf(IncompleteChatResponseError); + expect(failure).toBeInstanceOf(IncompleteNinaResponseError); expect(failure).toMatchObject({ reason: "open-stream-part", responseMessageId: "assistant-streaming-tool", @@ -139,7 +140,7 @@ describe("app/api/chat/response", () => { }); it("rejects a response with an open dynamic tool part", () => { - const failure = getAssistantResponseFailure({ + const failure = getNinaResponseFailure({ isAborted: false, responseMessage: { id: "assistant-dynamic-tool", @@ -157,7 +158,7 @@ describe("app/api/chat/response", () => { }, }); - expect(failure).toBeInstanceOf(IncompleteChatResponseError); + expect(failure).toBeInstanceOf(IncompleteNinaResponseError); expect(failure).toMatchObject({ reason: "open-stream-part", responseMessageId: "assistant-dynamic-tool", @@ -165,7 +166,7 @@ describe("app/api/chat/response", () => { }); it("accepts a response with terminal tool output and final text", () => { - const failure = getAssistantResponseFailure({ + const failure = getNinaResponseFailure({ isAborted: false, responseMessage: { id: "assistant-terminal-tool", diff --git a/apps/www/app/api/chat/response.ts b/packages/ai/nina/runtime/finish.ts similarity index 78% rename from apps/www/app/api/chat/response.ts rename to packages/ai/nina/runtime/finish.ts index 62c806d85e..8ab3427815 100644 --- a/apps/www/app/api/chat/response.ts +++ b/packages/ai/nina/runtime/finish.ts @@ -2,8 +2,8 @@ import type { MyUIMessage, MyUIMessagePart } from "@repo/ai/types/message"; import { Schema } from "effect"; /** Raised when AI SDK finishes without a durable assistant answer. */ -export class IncompleteChatResponseError extends Schema.TaggedError()( - "IncompleteChatResponseError", +export class IncompleteNinaResponseError extends Schema.TaggedError()( + "IncompleteNinaResponseError", { finishReason: Schema.optional(Schema.String), message: Schema.String, @@ -12,27 +12,27 @@ export class IncompleteChatResponseError extends Schema.TaggedError { + it("lets a repaired Nakafa call consume the only current-page fetch", () => { + const pageFetch = createPageFetchState(true); + + expect(pageFetch.reserveForRepair()).toBe(true); + expect(pageFetch.consumeForTool()).toBe(true); + expect(pageFetch.reserveForRepair()).toBe(false); + expect(pageFetch.consumeForTool()).toBe(false); + }); + + it("lets the first normal Nakafa execution claim current-page fetch once", () => { + const pageFetch = createPageFetchState(true); + + expect(pageFetch.consumeForTool()).toBe(true); + expect(pageFetch.reserveForRepair()).toBe(false); + expect(pageFetch.consumeForTool()).toBe(false); + }); + + it("rejects repair and execution claims when the page does not need fetching", () => { + const pageFetch = createPageFetchState(false); + + expect(pageFetch.reserveForRepair()).toBe(false); + expect(pageFetch.consumeForTool()).toBe(false); + }); + + it("canonicalizes absolute and relative Nakafa URLs to clean content refs", () => { + expect(getCanonicalNakafaContentUrl("/en/subjects/math/")).toBe( + "https://nakafa.com/en/subjects/math" + ); + expect( + getCanonicalNakafaContentUrl( + "https://nakafa.com/en/subjects/math?tab=practice" + ) + ).toBe("https://nakafa.com/en/subjects/math"); + }); + + it("detects completed current-page content evidence in retained messages", () => { + expect( + hasFetchedCurrentPageContent({ + messages: [ + messageWithPart({ type: "text", text: "Visible answer." }), + messageWithPart({ + id: "taxonomy", + type: "data-nakafa", + data: { + kind: "taxonomy", + status: "done", + input: { locale: "en" }, + result: { + content_counts: [], + locale: "en", + sections: [], + tools: [], + }, + }, + }), + messageWithPart({ + id: "loading-content", + type: "data-nakafa", + data: { + input: { + content_ref: NakafaAgentContentRefInputSchema.make( + "https://nakafa.com/en/subjects/math" + ), + }, + kind: "content", + status: "loading", + }, + }), + messageWithPart({ + id: "wrong-content", + type: "data-nakafa", + data: { + kind: "content", + status: "done", + input: { + content_ref: NakafaAgentContentRefInputSchema.make( + "https://nakafa.com/en/subjects/science" + ), + }, + result: contentSummary("https://nakafa.com/en/subjects/science"), + }, + }), + messageWithPart({ + id: "current-content", + type: "data-nakafa", + data: { + kind: "content", + status: "done", + input: { + content_ref: NakafaAgentContentRefInputSchema.make( + "https://nakafa.com/en/subjects/math" + ), + }, + result: contentSummary("https://nakafa.com/en/subjects/math/"), + }, + }), + ], + url: "/en/subjects/math", + }) + ).toBe(true); + }); + + it("keeps page fetch disabled for unverified pages or retained evidence", () => { + const fetchedMessages = [ + messageWithPart({ + id: "current-content", + type: "data-nakafa", + data: { + kind: "content", + status: "done", + input: { + content_ref: NakafaAgentContentRefInputSchema.make( + "https://nakafa.com/en/subjects/math" + ), + }, + result: contentSummary("https://nakafa.com/en/subjects/math"), + }, + }), + ]; + + expect( + determinePageFetchNeed({ + messages: [], + url: "/en/subjects/math", + verified: false, + }) + ).toBe(false); + expect( + determinePageFetchNeed({ + messages: fetchedMessages, + url: "/en/subjects/math", + verified: true, + }) + ).toBe(false); + }); + + it("requests a page fetch for verified pages without retained content evidence", () => { + expect( + determinePageFetchNeed({ + messages: [], + url: "/en/subjects/math", + verified: true, + }) + ).toBe(true); + }); +}); + +/** Creates a minimal assistant message with one UI part for page evidence tests. */ +function messageWithPart(part: MyUIMessage["parts"][number]): MyUIMessage { + return { + id: crypto.randomUUID(), + role: "assistant", + parts: [part], + }; +} + +/** Creates a valid Nakafa content summary for current-page evidence tests. */ +function contentSummary(url: string): NakafaAgentContentSummary { + return { + alignmentId: "alignment:math:vector", + assetId: "asset:material:math:vector", + conceptId: "concept:math:vector", + content_id: NakafaAgentContentIdSchema.make("asset:material:math:vector"), + description: "Vector material summary.", + learningObjectId: "learning:math:vector", + lensId: "lens:math:vector", + locale: englishLocale, + markdown_url: NakafaAgentMarkdownUrlSchema.make( + "https://nakafa.com/en/subjects/math.md" + ), + route: NakafaAgentContentRouteSchema.make("subjects/math"), + section: materialSection, + title: "Vector Math", + url: NakafaAgentContentUrlSchema.make(url), + }; +} diff --git a/packages/ai/nina/runtime/page.ts b/packages/ai/nina/runtime/page.ts new file mode 100644 index 0000000000..5cef943642 --- /dev/null +++ b/packages/ai/nina/runtime/page.ts @@ -0,0 +1,106 @@ +import type { MyUIMessage } from "@repo/ai/types/message"; +import { cleanSlug } from "@repo/utilities/helper"; + +/** Converts an absolute or relative content URL into Nakafa's canonical origin. */ +export function getCanonicalNakafaContentUrl(url: string) { + const parsedUrl = new URL(url, "https://nakafa.com"); + const slug = cleanSlug(parsedUrl.pathname); + + return `https://nakafa.com/${slug}`; +} + +/** Coordinates the one permitted current-page fetch across repair and execution. */ +export function createPageFetchState(needsPageFetch: boolean) { + let consumed = false; + let repairReservation = false; + + /** Reserves the one current-page fetch for a repaired Nakafa tool call. */ + function reserveForRepair() { + if (consumed || !needsPageFetch) { + return false; + } + + consumed = true; + repairReservation = true; + return true; + } + + /** Claims the current-page fetch for a Nakafa tool execution. */ + function consumeForTool() { + if (repairReservation) { + repairReservation = false; + return true; + } + + if (consumed || !needsPageFetch) { + return false; + } + + consumed = true; + return true; + } + + return { + consumeForTool, + reserveForRepair, + }; +} + +/** Normalizes absolute and relative content URLs to a comparable slug. */ +function normalizeContentRefUrl(url: string) { + const canonicalUrl = getCanonicalNakafaContentUrl(url); + const parsedUrl = new URL(canonicalUrl, "https://nakafa.com"); + return cleanSlug(parsedUrl.pathname); +} + +/** Checks whether retained chat context already has current-page evidence. */ +export function hasFetchedCurrentPageContent({ + messages, + url, +}: { + readonly messages: MyUIMessage[]; + readonly url: string; +}) { + const currentUrl = normalizeContentRefUrl(url); + + for (const message of messages) { + for (const part of message.parts) { + if (part.type !== "data-nakafa") { + continue; + } + + if (part.data.kind !== "content") { + continue; + } + + if (part.data.status !== "done") { + continue; + } + + const resultUrl = normalizeContentRefUrl(part.data.result.url); + + if (resultUrl === currentUrl) { + return true; + } + } + } + + return false; +} + +/** Decides whether the verified current page still needs a content fetch. */ +export function determinePageFetchNeed({ + messages, + url, + verified, +}: { + readonly messages: MyUIMessage[]; + readonly url: string; + readonly verified: boolean; +}) { + if (!verified) { + return false; + } + + return !hasFetchedCurrentPageContent({ messages, url }); +} diff --git a/packages/ai/nina/runtime/repair.ts b/packages/ai/nina/runtime/repair.ts new file mode 100644 index 0000000000..34ec099d50 --- /dev/null +++ b/packages/ai/nina/runtime/repair.ts @@ -0,0 +1,127 @@ +import { provider } from "@repo/ai/config/app"; +import { + defaultModel, + getFastModelProviderOptions, +} from "@repo/ai/config/model"; +import { gatewayProviderOptions } from "@repo/ai/config/routing"; +import { backgroundGenerationTimeout } from "@repo/ai/config/timeouts"; +import { NAKAFA_CAPABILITY } from "@repo/ai/nina/capability/spec"; +import type { NinaReporter } from "@repo/ai/nina/runtime/report"; +import type { NinaToolSet } from "@repo/ai/nina/runtime/step"; +import { logError } from "@repo/utilities/logging/effect"; +import type { LogContext } from "@repo/utilities/logging/types"; +import { + generateText, + InvalidToolInputError, + NoSuchToolError, + Output, + type ToolCallRepairFunction, +} from "ai"; +import { type Context, Effect } from "effect"; + +type NinaRepairOptions = Parameters>[0] & { + readonly reporter: Context.Tag.Service; + readonly reservePageFetch: () => boolean; + readonly sessionLogger: LogContext; + readonly url: string; +}; + +/** + * Recovers invalid Nina tool calls without permitting repeated page fetches. + * + * The input shape derives from AI SDK's `ToolCallRepairFunction`; this Module + * only adds the request-scoped page-fetch reservation and diagnostics service. + */ +export const repairNinaToolCall = Effect.fn("nina.repair.toolCall")(function* ({ + error, + inputSchema, + reporter, + reservePageFetch, + sessionLogger, + toolCall, + tools, + url, +}: NinaRepairOptions) { + yield* logError(error, { + ...sessionLogger, + errorLocation: "experimental_repairToolCall", + toolName: toolCall.toolName, + toolInput: toolCall.input, + errorType: error.name, + }); + + if (NoSuchToolError.isInstance(error)) { + yield* Effect.logWarning("Invalid tool name, not attempting recovery").pipe( + Effect.annotateLogs(sessionLogger) + ); + return null; + } + + if ( + toolCall.toolName === NAKAFA_CAPABILITY && + InvalidToolInputError.isInstance(error) && + reservePageFetch() + ) { + yield* Effect.logInfo("Using server-derived Nakafa input").pipe( + Effect.annotateLogs(sessionLogger) + ); + return { + ...toolCall, + input: JSON.stringify( + { + deliverables: ["current page evidence"], + objective: "Read the current Nakafa page.", + request: url, + requirements: ["Use the current page URL."], + }, + null, + 2 + ), + }; + } + + const tool = tools[toolCall.toolName]; + if (!tool) { + yield* Effect.logWarning( + "Tool is unavailable, not attempting recovery" + ).pipe(Effect.annotateLogs(sessionLogger)); + return null; + } + + const schema = yield* Effect.tryPromise({ + try: () => inputSchema(toolCall), + catch: (cause) => { + reporter.report({ error: cause, source: "repair.inputSchema" }); + return cause; + }, + }); + const { output: recoveredArgs } = yield* Effect.tryPromise({ + try: () => + generateText({ + model: provider.languageModel(defaultModel), + output: Output.object({ schema: tool.inputSchema }), + prompt: [ + `The model tried to call the tool "${toolCall.toolName}"` + + " with the following arguments:", + JSON.stringify(toolCall.input, null, 2), + "The tool accepts the following schema:", + JSON.stringify(schema, null, 2), + "Please fix the arguments.", + ].join("\n"), + providerOptions: { + gateway: gatewayProviderOptions, + google: getFastModelProviderOptions(defaultModel), + }, + timeout: backgroundGenerationTimeout, + }), + catch: (cause) => cause, + }); + + yield* Effect.logInfo("Tool call successfully recovered").pipe( + Effect.annotateLogs(sessionLogger) + ); + return { + ...toolCall, + input: JSON.stringify(recoveredArgs, null, 2), + }; +}); diff --git a/packages/ai/nina/runtime/report.ts b/packages/ai/nina/runtime/report.ts new file mode 100644 index 0000000000..2a05883079 --- /dev/null +++ b/packages/ai/nina/runtime/report.ts @@ -0,0 +1,17 @@ +import { Context, type Effect } from "effect"; + +/** + * Request-scoped diagnostics seam for NinaHarness. + * + * App adapters can attach deployment-specific logging, analytics, and error + * capture without exposing those callbacks through the public harness input. + */ +export class NinaReporter extends Context.Tag("NinaReporter")< + NinaReporter, + { + readonly report: (input: { + readonly error: unknown; + readonly source: string; + }) => Effect.Effect; + } +>() {} diff --git a/apps/www/app/api/chat/step.test.ts b/packages/ai/nina/runtime/step.test.ts similarity index 66% rename from apps/www/app/api/chat/step.test.ts rename to packages/ai/nina/runtime/step.test.ts index b580bc91c2..9756ff7416 100644 --- a/apps/www/app/api/chat/step.test.ts +++ b/packages/ai/nina/runtime/step.test.ts @@ -1,8 +1,6 @@ -// @vitest-environment node import type { ModelMessage } from "ai"; -import { Effect } from "effect"; import { describe, expect, it } from "vitest"; -import { prepareChatStep } from "@/app/api/chat/step"; +import { createNinaPrepareStep, type NinaPrepareStep } from "./step"; const emptyMessages = [] satisfies ModelMessage[]; const system = "Base system prompt"; @@ -14,16 +12,13 @@ const externalUrlMessages = [ }, ] satisfies ModelMessage[]; -describe("app/api/chat/step", () => { - it("forces Nakafa on the first page-fetch step", () => { - const step = Effect.runSync( - prepareChatStep({ - messages: emptyMessages, - needsPageFetch: true, - system, - stepNumber: 0, - }) - ); +describe("nina/runtime/step", () => { + it("forces Nakafa on the first page-fetch step", async () => { + const step = await readPreparedStep({ + messages: emptyMessages, + needsPageFetch: true, + stepNumber: 0, + }); expect(step).toEqual({ activeTools: ["nakafa"], @@ -32,15 +27,12 @@ describe("app/api/chat/step", () => { }); }); - it("reinforces final source policy after the first page-fetch step", () => { - const step = Effect.runSync( - prepareChatStep({ - messages: emptyMessages, - needsPageFetch: true, - system, - stepNumber: 1, - }) - ); + it("reinforces final source policy after the first page-fetch step", async () => { + const step = await readPreparedStep({ + messages: emptyMessages, + needsPageFetch: true, + stepNumber: 1, + }); expect(step).toEqual({ messages: [], @@ -50,7 +42,7 @@ describe("app/api/chat/step", () => { }); }); - it("leaves low-risk first non-page-fetch prompts to the orchestrator prompt", () => { + it("leaves low-risk first non-page-fetch prompts to Nina's system prompt", async () => { const greetingMessages = [ { content: "hi", @@ -58,14 +50,11 @@ describe("app/api/chat/step", () => { }, ] satisfies ModelMessage[]; - const step = Effect.runSync( - prepareChatStep({ - messages: greetingMessages, - needsPageFetch: false, - system, - stepNumber: 0, - }) - ); + const step = await readPreparedStep({ + messages: greetingMessages, + needsPageFetch: false, + stepNumber: 0, + }); expect(step).toEqual({ messages: greetingMessages, @@ -74,15 +63,12 @@ describe("app/api/chat/step", () => { expect(step).not.toHaveProperty("toolChoice"); }); - it("reinforces final source policy after the first non-page-fetch step", () => { - const step = Effect.runSync( - prepareChatStep({ - messages: emptyMessages, - needsPageFetch: false, - system, - stepNumber: 1, - }) - ); + it("reinforces final source policy after the first non-page-fetch step", async () => { + const step = await readPreparedStep({ + messages: emptyMessages, + needsPageFetch: false, + stepNumber: 1, + }); expect(step).toEqual({ messages: [], @@ -148,15 +134,12 @@ describe("app/api/chat/step", () => { }); }); - it("forces research for first-step external URL requests", () => { - const step = Effect.runSync( - prepareChatStep({ - messages: externalUrlMessages, - needsPageFetch: false, - system, - stepNumber: 0, - }) - ); + it("forces research for first-step external URL requests", async () => { + const step = await readPreparedStep({ + messages: externalUrlMessages, + needsPageFetch: false, + stepNumber: 0, + }); expect(step).toEqual({ activeTools: ["deepResearch"], @@ -165,15 +148,12 @@ describe("app/api/chat/step", () => { }); }); - it("keeps page fetch ahead of external URL requests", () => { - const step = Effect.runSync( - prepareChatStep({ - messages: externalUrlMessages, - needsPageFetch: true, - system, - stepNumber: 0, - }) - ); + it("keeps page fetch ahead of external URL requests", async () => { + const step = await readPreparedStep({ + messages: externalUrlMessages, + needsPageFetch: true, + stepNumber: 0, + }); expect(step).toEqual({ activeTools: ["nakafa"], @@ -182,7 +162,7 @@ describe("app/api/chat/step", () => { }); }); - it("keeps prepared model messages available to later model steps", () => { + it("keeps prepared model messages available to later model steps", async () => { const messages = [ { content: "Cek kabar tryout.", @@ -194,19 +174,16 @@ describe("app/api/chat/step", () => { }, ] satisfies ModelMessage[]; - const step = Effect.runSync( - prepareChatStep({ - messages, - needsPageFetch: false, - system, - stepNumber: 1, - }) - ); + const step = await readPreparedStep({ + messages, + needsPageFetch: false, + stepNumber: 1, + }); - expect(step.messages).toEqual(messages); + expect(step?.messages).toEqual(messages); }); - it("leaves continuation tool choice to the model", () => { + it("leaves continuation tool choice to the model", async () => { const messages = [ { content: [ @@ -230,14 +207,11 @@ describe("app/api/chat/step", () => { }, ] satisfies ModelMessage[]; - const step = Effect.runSync( - prepareChatStep({ - messages, - needsPageFetch: false, - system, - stepNumber: 1, - }) - ); + const step = await readPreparedStep({ + messages, + needsPageFetch: false, + stepNumber: 1, + }); expect(step).toEqual({ messages, @@ -247,3 +221,30 @@ describe("app/api/chat/step", () => { expect(step).not.toHaveProperty("toolChoice"); }); }); + +/** + * Runs Nina's deterministic step policy through the AI SDK callback contract. + * + * Tests exercise the public SDK-derived callback shape so package behavior + * stays aligned with `ToolLoopAgentSettings["prepareStep"]` instead of a local + * imitation of the callback input. + */ +function readPreparedStep({ + messages, + needsPageFetch, + stepNumber, +}: { + readonly messages: ModelMessage[]; + readonly needsPageFetch: boolean; + readonly stepNumber: number; +}) { + const prepareStep = createNinaPrepareStep({ needsPageFetch, system }); + + return prepareStep({ + experimental_context: undefined, + messages, + model: "google/gemini-3-flash", + stepNumber, + steps: [], + } satisfies Parameters[0]); +} diff --git a/packages/ai/nina/runtime/step.ts b/packages/ai/nina/runtime/step.ts new file mode 100644 index 0000000000..f708364fd6 --- /dev/null +++ b/packages/ai/nina/runtime/step.ts @@ -0,0 +1,118 @@ +import { getSourceReferencesFromMessages } from "@repo/ai/lib/source"; +import { + type LearningCapabilityName, + NAKAFA_CAPABILITY, + RESEARCH_CAPABILITY, +} from "@repo/ai/nina/capability/spec"; +import { createPrompt } from "@repo/ai/prompt/utils"; +import type { Tool, ToolLoopAgentSettings, ToolSet } from "ai"; + +const firstStepNumber = 0; + +type RequiredStepToolName = + | typeof NAKAFA_CAPABILITY + | typeof RESEARCH_CAPABILITY; + +export type NinaToolSet = ToolSet & Record; + +/** AI SDK-derived callback type for Nina's per-step tool policy. */ +export type NinaPrepareStep = NonNullable< + ToolLoopAgentSettings["prepareStep"] +>; + +/** + * Creates Nina's AI SDK step callback from verified page-fetch state. + * + * The returned function uses the SDK-owned `prepareStep` contract and only + * decides first-step evidence routing plus continuation source policy; it does + * not own ToolLoopAgent wiring or duplicate the SDK callback input shape. + */ +export function createNinaPrepareStep({ + needsPageFetch, + system, +}: { + readonly needsPageFetch: boolean; + readonly system: string; +}): NinaPrepareStep { + return ({ messages, stepNumber }) => { + if (stepNumber !== firstStepNumber) { + return { + messages, + system: [ + system, + createPrompt({ + taskContext: ` + # Continuation Source Policy + + Continue from the evidence already gathered in earlier steps. + Preserve every source constraint from the user request and the specialist evidence. + `, + toolUsageGuidelines: ` + # Continuation Tool Guidance + + Continue with the model's tool choice, using gathered evidence as the decision source. + + Call math before the final answer when: + - Nakafa selected educational math content. + - The final answer will include calculations, formulas, numeric answers, answer keys, or correctness claims. + + The math input must verify the exact example, exercise, answer key, and numeric claims that will appear in the final answer. + + Do not call math after Nakafa when: + - The content is a non-math lesson, Quran, article, or definition without calculation. + - The source summary contains no mathematical verification target. + + After math returns, do not switch to different mathematical content unless you call math again for that replacement content. + `, + outputFormatting: ` + # User-Facing Citation Format + + Cite external research sources inline in the exact sentence they support. + Use [text](url) links with concise, human-readable text. + Use only links already present in external research evidence or current page context. + Do not add product homepages, documentation links, or source links from memory. + When research evidence contains markdown links, preserve those links in the final answer for every claim that uses that evidence. + If the answer has sections or bullets built from source-backed research, each source-backed section or bullet must keep at least one supporting link. + Do not add Nakafa source labels, Nakafa domain links, or citation-style links for Nakafa-owned content. + Never show numeric citation markers such as [1] or [4, 21, 23] to users. + Convert any research citation indexes into markdown links using the cited source URLs. + Never append a final source, reference, citation, or bibliography section in any language. + Do not collect links at the end of the answer. + `, + }), + ].join("\n\n"), + }; + } + + if (needsPageFetch) { + return readToolStep({ + messages, + toolName: NAKAFA_CAPABILITY, + }); + } + + if (getSourceReferencesFromMessages(messages).length > 0) { + return readToolStep({ + messages, + toolName: RESEARCH_CAPABILITY, + }); + } + + return { messages }; + }; +} + +/** Builds the SDK-owned first-step shape for one required Nina evidence tool. */ +function readToolStep({ + messages, + toolName, +}: { + readonly messages: Parameters[0]["messages"]; + readonly toolName: Extract; +}): ReturnType { + return { + activeTools: [toolName], + messages, + toolChoice: { toolName, type: "tool" }, + }; +} diff --git a/packages/ai/nina/runtime/store.ts b/packages/ai/nina/runtime/store.ts new file mode 100644 index 0000000000..ba5faedc75 --- /dev/null +++ b/packages/ai/nina/runtime/store.ts @@ -0,0 +1,39 @@ +import type { CapabilityTrace } from "@repo/ai/nina/capability/spec"; +import type { NinaContextPack } from "@repo/ai/nina/memory/pack"; +import type { MyUIMessage } from "@repo/ai/types/message"; +import { Context, type Effect, Schema } from "effect"; + +/** Raised when the app-owned chat persistence adapter fails during a Nina turn. */ +export class NinaStoreError extends Schema.TaggedError()( + "NinaStoreError", + { + message: Schema.String, + source: Schema.String, + } +) {} + +/** + * Request-scoped persistence seam used by NinaHarness. + * + * The app owns Convex deployment details and binds them into this service at + * the route boundary; the harness owns when stream lifecycle events persist. + */ +export class NinaStore extends Context.Tag("NinaStore")< + NinaStore, + { + readonly loadMessages: () => Effect.Effect; + readonly saveAssistant: (input: { + readonly context: NinaContextPack; + readonly responseMessage: MyUIMessage; + }) => Effect.Effect; + readonly saveFailure: (input: { + readonly responseMessageId: string; + }) => Effect.Effect; + readonly saveTrace: ( + input: CapabilityTrace + ) => Effect.Effect; + readonly saveTitle: (input: { + readonly messages: MyUIMessage[]; + }) => Effect.Effect; + } +>() {} diff --git a/packages/ai/nina/runtime/stream.ts b/packages/ai/nina/runtime/stream.ts new file mode 100644 index 0000000000..175565c4ea --- /dev/null +++ b/packages/ai/nina/runtime/stream.ts @@ -0,0 +1,285 @@ +import { NakafaSearch } from "@repo/ai/agents/nakafa/search"; +import { Nakafa } from "@repo/ai/agents/nakafa/service"; +import { compressMessages } from "@repo/ai/lib/message"; +import { createNinaCapabilityCatalog } from "@repo/ai/nina/capability/catalog"; +import { + createNinaAgentContext, + type NinaTurn, + readNinaLearningPage, +} from "@repo/ai/nina/contract/turn"; +import { + type NinaAgentMessages, + runNinaAgentTurn, +} from "@repo/ai/nina/runtime/agent"; +import { formatNinaStreamError } from "@repo/ai/nina/runtime/error"; +import { getNinaResponseFailure } from "@repo/ai/nina/runtime/finish"; +import { createNinaLogContext } from "@repo/ai/nina/runtime/log"; +import { + createPageFetchState, + determinePageFetchNeed, +} from "@repo/ai/nina/runtime/page"; +import { repairNinaToolCall } from "@repo/ai/nina/runtime/repair"; +import { NinaReporter } from "@repo/ai/nina/runtime/report"; +import { NinaStore } from "@repo/ai/nina/runtime/store"; +import { writeNinaSuggestions } from "@repo/ai/nina/runtime/suggest"; +import { trackUsage } from "@repo/ai/nina/runtime/usage"; +import type { MyUIMessage } from "@repo/ai/types/message"; +import type { LogContext } from "@repo/utilities/logging/types"; +import { + convertToModelMessages, + createUIMessageStream, + createUIMessageStreamResponse, + generateId, + pruneMessages, + type UIMessageStreamWriter, +} from "ai"; +import { Effect, Schema } from "effect"; + +/** Raised when NinaHarness cannot prepare or stream one turn. */ +export class NinaStreamError extends Schema.TaggedError()( + "NinaStreamError", + { + message: Schema.String, + source: Schema.String, + } +) {} + +/** Creates the framework-native AI SDK stream response for one Nina turn. */ +export const createNinaStreamResponse = Effect.fn("nina.stream.response")( + function* (turn: NinaTurn) { + const store = yield* NinaStore; + const reporter = yield* NinaReporter; + const nakafa = yield* Nakafa; + const search = yield* NakafaSearch; + const messages = yield* store.loadMessages(); + const logContext = createNinaLogContext(turn); + const originalMessageCount = messages.length; + const { messages: compressedMessages, tokens } = compressMessages(messages); + const learningPage = readNinaLearningPage(turn.page); + const page = { + ...turn.page, + needsFetch: determinePageFetchNeed({ + messages: compressedMessages, + url: learningPage.url, + verified: learningPage.verified, + }), + }; + const isFirstMessage = messages.length === 1; + const responseMessageId = generateId(); + let failureScheduled = false; + + if (compressedMessages.length < originalMessageCount) { + yield* Effect.logWarning( + `Messages compressed from ${originalMessageCount} to ${compressedMessages.length} messages (${tokens} tokens) to stay within token limit` + ).pipe(Effect.annotateLogs(logContext)); + } else { + yield* Effect.logInfo( + `All ${originalMessageCount} messages fit within token limit (${tokens} tokens)` + ).pipe(Effect.annotateLogs(logContext)); + } + + const modelMessages = yield* Effect.tryPromise({ + try: () => convertToModelMessages(compressedMessages), + catch: () => + new NinaStreamError({ + message: "Unable to convert UI messages for Nina model input.", + source: "convertToModelMessages", + }), + }); + const finalMessages = pruneMessages({ + messages: modelMessages, + reasoning: "all", + }); + + yield* Effect.logInfo("Nina chat session started").pipe( + Effect.annotateLogs(logContext) + ); + + /** Schedules durable failure handling once for a failed Nina stream. */ + function scheduleAssistantFailure(error: unknown, source: string) { + if (failureScheduled) { + return; + } + + failureScheduled = true; + Effect.runFork( + Effect.all( + [ + reporter.report({ error, source }), + store.saveFailure({ responseMessageId }).pipe( + Effect.catchAll((saveError) => + reporter.report({ + error: saveError, + source: "saveAssistantFailure", + }) + ) + ), + ], + { discard: true } + ) + ); + } + + const stream = createUIMessageStream({ + /** Runs Nina's Effect program at the AI SDK stream callback boundary. */ + execute: ({ writer }) => + Effect.runPromise( + runNinaWriterTurn({ + finalMessages, + logContext, + onStreamError: scheduleAssistantFailure, + page, + responseMessageIdentifier: responseMessageId, + runtime: turn.runtime, + copy: turn.copy, + user: turn.user, + writer, + }).pipe( + Effect.provideService(NinaStore, store), + Effect.provideService(NinaReporter, reporter), + Effect.provideService(Nakafa, nakafa), + Effect.provideService(NakafaSearch, search) + ) + ), + generateId: () => responseMessageId, + onError: (error) => { + scheduleAssistantFailure(error, "createUIMessageStream"); + return formatNinaStreamError({ error, logContext, turn }); + }, + onFinish: ({ + finishReason, + isAborted, + messages: updatedMessages, + responseMessage, + }) => { + if (failureScheduled) { + return; + } + + const responseFailure = getNinaResponseFailure({ + finishReason, + isAborted, + responseMessage, + }); + + if (responseFailure) { + scheduleAssistantFailure(responseFailure, "ninaResponseFinalization"); + return; + } + + if (isFirstMessage) { + Effect.runFork( + store + .saveTitle({ messages: updatedMessages }) + .pipe( + Effect.catchAll((error) => + reporter.report({ error, source: "saveTitle" }) + ) + ) + ); + } + + Effect.runFork( + store + .saveAssistant({ + context: page.nina, + responseMessage, + }) + .pipe( + Effect.catchAll((error) => + reporter.report({ error, source: "saveAssistantResponse" }) + ) + ) + ); + }, + originalMessages: compressedMessages, + }); + + return createUIMessageStreamResponse({ stream }); + } +); + +/** Streams one Nina ToolLoopAgent turn into the AI SDK UI writer. */ +const runNinaWriterTurn = Effect.fn("nina.stream.writer")(function* ({ + finalMessages, + logContext, + onStreamError, + copy, + page, + responseMessageIdentifier, + runtime, + user, + writer, +}: { + readonly copy: NinaTurn["copy"]; + readonly finalMessages: NinaAgentMessages; + readonly logContext: LogContext; + readonly onStreamError: (error: unknown, source: string) => void; + readonly page: NinaTurn["page"]; + readonly responseMessageIdentifier: string; + readonly runtime: NinaTurn["runtime"]; + readonly user: NinaTurn["user"]; + readonly writer: UIMessageStreamWriter; +}) { + const usage = yield* trackUsage(); + const context = createNinaAgentContext({ page, runtime, user }); + const pageFetch = createPageFetchState(context.needsPageFetch); + const reporter = yield* NinaReporter; + const tools = yield* createNinaCapabilityCatalog({ + context, + locale: page.nina.learning.locale, + logContext, + modelId: runtime.modelId, + responseMessageIdentifier, + consumePageFetch: pageFetch.consumeForTool, + usage, + writer, + }); + + const responseMessages = yield* runNinaAgentTurn({ + messages: finalMessages, + page, + runtime, + settings: { + experimental_repairToolCall: (options) => + Effect.runPromise( + repairNinaToolCall({ + ...options, + reporter, + reservePageFetch: pageFetch.reserveForRepair, + sessionLogger: logContext, + url: context.url, + }) + ), + tools, + }, + stream: { + formatError: (error) => + formatNinaStreamError({ + error, + logContext, + turn: { page, runtime, user, copy }, + }), + onError: onStreamError, + readFinishMetadata: (mainUsage) => + Effect.runSync( + usage.metadata({ + mainUsage, + modelId: runtime.modelId, + }) + ), + writer, + }, + user, + }); + + yield* writeNinaSuggestions({ + locale: page.locale, + messages: [...finalMessages, ...responseMessages], + writer, + }).pipe( + Effect.catchAll((error) => + reporter.report({ error, source: "writeNinaSuggestions" }) + ) + ); +}); diff --git a/packages/ai/nina/runtime/suggest.test.ts b/packages/ai/nina/runtime/suggest.test.ts new file mode 100644 index 0000000000..c44a6eff62 --- /dev/null +++ b/packages/ai/nina/runtime/suggest.test.ts @@ -0,0 +1,283 @@ +// @vitest-environment node + +import { suggestionGenerationTimeout } from "@repo/ai/config/timeouts"; +import { writeNinaSuggestions } from "@repo/ai/nina/runtime/suggest"; +import type { MyUIMessage } from "@repo/ai/types/message"; +import type { ModelMessage, UIMessageStreamWriter } from "ai"; +import { Effect, Either } from "effect"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const streamText = vi.hoisted(() => vi.fn()); + +vi.mock("@repo/ai/config/app", () => ({ + provider: { + languageModel: (modelId: string) => modelId, + }, +})); + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + streamText, + }; +}); + +const messages = [ + { + content: "Halo Nina.", + role: "user", + }, +] satisfies ModelMessage[]; + +/** Creates an async iterable that emits the provided suggestion partials. */ +async function* suggestionPartials( + chunks: readonly { readonly suggestions?: readonly string[] }[] +) { + for (const chunk of chunks) { + await Promise.resolve(); + yield chunk; + } +} + +/** Creates an async iterable that fails while Nina reads streamed suggestions. */ +async function* failingSuggestionPartials() { + await Promise.resolve(); + yield await Promise.reject(new Error("partial stream failed")); +} + +/** Captures suggestion data parts written by the Nina suggestion Module. */ +function createWriter() { + return { + merge: vi.fn(), + onError: undefined, + write: vi.fn(), + } satisfies UIMessageStreamWriter; +} + +describe("nina/runtime/suggest", () => { + beforeEach(() => { + streamText.mockReset(); + }); + + it("writes final suggestions when partial chunks are empty", async () => { + const writer = createWriter(); + streamText.mockReturnValue({ + output: Promise.resolve({ + suggestions: ["Apa contoh lainnya?", "Beri latihan singkat."], + }), + partialOutputStream: suggestionPartials([{}, { suggestions: [] }]), + }); + + await Effect.runPromise( + writeNinaSuggestions({ + locale: "id", + messages, + writer, + }) + ); + + expect(writer.write).toHaveBeenCalledWith( + expect.objectContaining({ + type: "data-suggestions", + data: { + data: ["Apa contoh lainnya?", "Beri latihan singkat."], + }, + }) + ); + }); + + it("prunes tool-call transcript parts before generating suggestions", async () => { + const writer = createWriter(); + const transcriptWithToolCall = [ + { + content: "Hitung 2 + 3.", + role: "user", + }, + { + content: [ + { + text: "Saya cek hitungannya.", + type: "text", + }, + { + input: { expression: "2+3" }, + toolCallId: "tool-1", + toolName: "math", + type: "tool-call", + }, + ], + role: "assistant", + }, + { + content: [ + { + output: { type: "text", value: "5" }, + toolCallId: "tool-1", + toolName: "math", + type: "tool-result", + }, + ], + role: "tool", + }, + { + content: "Karena dua benda ditambah tiga benda menjadi lima benda.", + role: "assistant", + }, + ] satisfies ModelMessage[]; + streamText.mockReturnValue({ + output: Promise.resolve({ + suggestions: ["Beri contoh benda nyata."], + }), + partialOutputStream: suggestionPartials([]), + }); + + await Effect.runPromise( + writeNinaSuggestions({ + locale: "id", + messages: transcriptWithToolCall, + writer, + }) + ); + + expect(streamText).toHaveBeenCalledWith( + expect.objectContaining({ + messages: [ + { content: "Hitung 2 + 3.", role: "user" }, + { + content: [ + { + text: "Saya cek hitungannya.", + type: "text", + }, + ], + role: "assistant", + }, + { + content: "Karena dua benda ditambah tiga benda menjadi lima benda.", + role: "assistant", + }, + ], + timeout: suggestionGenerationTimeout, + }) + ); + }); + + it("updates the same suggestions part when final output completes", async () => { + const writer = createWriter(); + streamText.mockReturnValue({ + output: Promise.resolve({ + suggestions: ["Apa contoh finalnya?", "Buat latihan final."], + }), + partialOutputStream: suggestionPartials([ + { suggestions: [] }, + { suggestions: ["Apa langkah berikutnya?"] }, + ]), + }); + + await Effect.runPromise( + writeNinaSuggestions({ + locale: "id", + messages, + writer, + }) + ); + + expect(writer.write).toHaveBeenCalledTimes(2); + const firstWrite = writer.write.mock.calls[0]?.[0]; + const finalWrite = writer.write.mock.calls[1]?.[0]; + + expect(firstWrite).toEqual( + expect.objectContaining({ + data: { + data: ["Apa langkah berikutnya?"], + }, + }) + ); + expect(finalWrite).toEqual( + expect.objectContaining({ + id: firstWrite?.id, + data: { + data: ["Apa contoh finalnya?", "Buat latihan final."], + }, + }) + ); + }); + + it("skips writing when the completed suggestions object is empty", async () => { + const writer = createWriter(); + streamText.mockReturnValue({ + output: Promise.resolve({ + suggestions: [], + }), + partialOutputStream: suggestionPartials([{}]), + }); + + await Effect.runPromise( + writeNinaSuggestions({ + locale: "id", + messages, + writer, + }) + ); + + expect(writer.write).not.toHaveBeenCalled(); + }); + + it("reports a typed failure when partial suggestion streaming fails", async () => { + const writer = createWriter(); + streamText.mockReturnValue({ + output: Promise.resolve({ + suggestions: ["Tidak dipakai."], + }), + partialOutputStream: failingSuggestionPartials(), + }); + + const result = await Effect.runPromise( + Effect.either( + writeNinaSuggestions({ + locale: "id", + messages, + writer, + }) + ) + ); + + expect(Either.isLeft(result)).toBe(true); + expect(Either.getLeft(result)).toMatchObject({ + _tag: "Some", + value: { + _tag: "NinaSuggestionError", + message: "Failed to stream Nina suggestions.", + }, + }); + }); + + it("reports a typed failure when final suggestion completion fails", async () => { + const writer = createWriter(); + streamText.mockReturnValue({ + output: Promise.reject(new Error("completion failed")), + partialOutputStream: suggestionPartials([{}]), + }); + + const result = await Effect.runPromise( + Effect.either( + writeNinaSuggestions({ + locale: "id", + messages, + writer, + }) + ) + ); + + expect(Either.isLeft(result)).toBe(true); + expect(Either.getLeft(result)).toMatchObject({ + _tag: "Some", + value: { + _tag: "NinaSuggestionError", + message: "Failed to complete Nina suggestions.", + }, + }); + }); +}); diff --git a/apps/www/app/api/chat/suggestions.ts b/packages/ai/nina/runtime/suggest.ts similarity index 58% rename from apps/www/app/api/chat/suggestions.ts rename to packages/ai/nina/runtime/suggest.ts index 710f03739f..4d158f9130 100644 --- a/apps/www/app/api/chat/suggestions.ts +++ b/packages/ai/nina/runtime/suggest.ts @@ -4,11 +4,11 @@ import { getFastModelProviderOptions, } from "@repo/ai/config/model"; import { gatewayProviderOptions } from "@repo/ai/config/routing"; -import { backgroundGenerationTimeout } from "@repo/ai/config/timeouts"; +import { suggestionGenerationTimeout } from "@repo/ai/config/timeouts"; import { createEffectSchema } from "@repo/ai/lib/effect-schema"; import { nakafaSuggestions } from "@repo/ai/prompt/suggestions"; import type { MyUIMessage } from "@repo/ai/types/message"; -import type { Locale } from "@repo/backend/convex/lib/validators/contents"; +import type { Locale } from "@repo/contents/_types/content"; import { type ModelMessage, Output, @@ -18,12 +18,6 @@ import { } from "ai"; import { Effect, Schema, Stream } from "effect"; -interface Params { - locale: Locale; - messages: ModelMessage[]; - writer: UIMessageStreamWriter; -} - const SuggestionsOutputSchema = createEffectSchema( Schema.Struct({ suggestions: Schema.Array(Schema.String).annotations({ @@ -33,21 +27,29 @@ const SuggestionsOutputSchema = createEffectSchema( }) ); +/** Raised when Nina cannot stream follow-up suggestions after an answer. */ +export class NinaSuggestionError extends Schema.TaggedError()( + "NinaSuggestionError", + { + message: Schema.String, + } +) {} + /** * Streams follow-up suggestions after the assistant response is complete. * * @see https://ai-sdk.dev/docs/reference/ai-sdk-core/output#output-object * @see https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text */ -export const writeSuggestions = Effect.fn("chat.writeSuggestions")(function* ({ +export const writeNinaSuggestions = Effect.fn("nina.suggest.write")(function* ({ locale, messages, writer, -}: Params) { - // Suggestions only need the visible conversation and final answer shape. - // Keep reasoning stored/rendered elsewhere, but remove it from this secondary - // model call so follow-up generation does not spend tokens on internal traces. - // https://ai-sdk.dev/docs/reference/ai-sdk-ui/prune-messages +}: { + readonly locale: Locale; + readonly messages: ModelMessage[]; + readonly writer: UIMessageStreamWriter; +}) { const promptMessages = pruneMessages({ messages, reasoning: "all", @@ -65,28 +67,58 @@ export const writeSuggestions = Effect.fn("chat.writeSuggestions")(function* ({ gateway: gatewayProviderOptions, google: getFastModelProviderOptions(defaultModel), }, - timeout: backgroundGenerationTimeout, + timeout: suggestionGenerationTimeout, }); const dataPartId = crypto.randomUUID(); yield* Stream.fromAsyncIterable( suggestionsStream.partialOutputStream, - (cause) => new Error("Failed to stream chat suggestions.", { cause }) + () => + new NinaSuggestionError({ + message: "Failed to stream Nina suggestions.", + }) ).pipe( Stream.runForEach((chunk) => Effect.sync(() => { + const suggestions = + chunk.suggestions?.filter((suggestion) => suggestion !== undefined) ?? + []; + + if (suggestions.length === 0) { + return; + } + writer.write({ id: dataPartId, type: "data-suggestions", data: { - data: - chunk.suggestions?.filter( - (suggestion) => suggestion !== undefined - ) ?? [], + data: suggestions, }, }); }) ) ); + + const output = yield* Effect.tryPromise({ + try: () => suggestionsStream.output, + catch: () => + new NinaSuggestionError({ + message: "Failed to complete Nina suggestions.", + }), + }); + + if (output.suggestions.length === 0) { + return; + } + + yield* Effect.sync(() => + writer.write({ + id: dataPartId, + type: "data-suggestions", + data: { + data: [...output.suggestions], + }, + }) + ); }); diff --git a/apps/www/app/api/chat/usage.test.ts b/packages/ai/nina/runtime/usage.test.ts similarity index 96% rename from apps/www/app/api/chat/usage.test.ts rename to packages/ai/nina/runtime/usage.test.ts index 26023964cf..8040ec57ab 100644 --- a/apps/www/app/api/chat/usage.test.ts +++ b/packages/ai/nina/runtime/usage.test.ts @@ -1,9 +1,9 @@ // @vitest-environment node import { defaultModel, getModelCreditCost } from "@repo/ai/config/model"; +import { trackUsage } from "@repo/ai/nina/runtime/usage"; import type { LanguageModelUsage } from "ai"; import { Effect } from "effect"; import { describe, expect, it } from "vitest"; -import { trackUsage } from "@/app/api/chat/usage"; /** * Returns one complete AI SDK usage row for usage-tracker tests. @@ -32,7 +32,7 @@ function usageRow({ } satisfies LanguageModelUsage; } -describe("app/api/chat/usage", () => { +describe("nina/runtime/usage", () => { it("tracks sub-agent usage and creates final metadata", () => { const usage = Effect.runSync(trackUsage()); diff --git a/apps/www/app/api/chat/usage.ts b/packages/ai/nina/runtime/usage.ts similarity index 66% rename from apps/www/app/api/chat/usage.ts rename to packages/ai/nina/runtime/usage.ts index c0d8b18a96..1dd68b2bd0 100644 --- a/apps/www/app/api/chat/usage.ts +++ b/packages/ai/nina/runtime/usage.ts @@ -1,19 +1,20 @@ import { getModelCreditCost, type ModelId } from "@repo/ai/config/model"; +import type { LearningCapabilityName } from "@repo/ai/nina/capability/spec"; import type { ComponentUsage } from "@repo/ai/schema/metadata"; -import type { ToolName } from "@repo/ai/schema/tools"; import type { LanguageModelUsage } from "ai"; import { Effect, Ref } from "effect"; type MainUsage = Pick; -/** - * Tracks per-turn usage for sub-agent token costs. - */ -export const trackUsage = Effect.fn("chat.trackUsage")(function* () { - const subAgents = yield* Ref.make(new Map()); +/** Tracks main and specialist token usage for one Nina harness turn. */ +export const trackUsage = Effect.fn("nina.usage.track")(function* () { + const subAgents = yield* Ref.make( + new Map() + ); - const addUsage = Effect.fn("chat.usage.addUsage")(function* ( - component: ToolName, + /** Adds one specialist usage row to the per-turn aggregate. */ + const addUsage = Effect.fn("nina.usage.add")(function* ( + component: LearningCapabilityName, usage: LanguageModelUsage ) { yield* Ref.update(subAgents, (current) => { @@ -31,12 +32,13 @@ export const trackUsage = Effect.fn("chat.trackUsage")(function* () { }); }); - const metadata = Effect.fn("chat.usage.metadata")(function* ({ + /** Builds persisted Nina metadata from main-model and specialist usage. */ + const metadata = Effect.fn("nina.usage.metadata")(function* ({ mainUsage, modelId, }: { - mainUsage: MainUsage; - modelId: ModelId; + readonly mainUsage: MainUsage; + readonly modelId: ModelId; }) { const usages = yield* Ref.get(subAgents); const mainInput = mainUsage.inputTokens ?? 0; @@ -64,10 +66,10 @@ export const trackUsage = Effect.fn("chat.trackUsage")(function* () { }; }); -/** - * Sums input, output, and total tokens from component usage rows. - */ -function getTotals(usages: ReadonlyMap) { +/** Sums input, output, and total tokens from component usage rows. */ +function getTotals( + usages: ReadonlyMap +) { const input = Array.from(usages.values()).reduce( (sum, usage) => sum + usage.input, 0 diff --git a/packages/ai/prompt/learning-profile.test.ts b/packages/ai/prompt/learning-profile.test.ts index bb9048f812..91d8f2aabb 100644 --- a/packages/ai/prompt/learning-profile.test.ts +++ b/packages/ai/prompt/learning-profile.test.ts @@ -1,5 +1,6 @@ import { formatLearningProfilePromptContext } from "@repo/ai/prompt/learning-profile"; import type { AgentLearningProfile } from "@repo/ai/types/agents"; +import { LearningProgramKeySchema } from "@repo/contents/_types/program/schema"; import { describe, expect, it } from "vitest"; const baseLearningProfile: AgentLearningProfile = { @@ -7,7 +8,7 @@ const baseLearningProfile: AgentLearningProfile = { planItems: [], program: { coverageStatus: "partial", - key: "snbt-2026", + key: LearningProgramKeySchema.make("snbt-2026"), kind: "admission-exam", title: "SNBT 2026", versionLabel: "2026", diff --git a/packages/ai/schema/metadata.ts b/packages/ai/schema/metadata.ts index eb7b22dfaa..0f8b558b81 100644 --- a/packages/ai/schema/metadata.ts +++ b/packages/ai/schema/metadata.ts @@ -1,5 +1,9 @@ import { CHAT_GENERATION_FAILURE_CODES } from "@repo/ai/config/generation"; import { ModelIdSchema } from "@repo/ai/config/model"; +import { + NinaContextSnapshotSchema, + NinaContextTransitionSchema, +} from "@repo/ai/nina/memory/pack"; import { Schema } from "effect"; const ComponentUsageSchema = Schema.Struct({ @@ -17,6 +21,8 @@ export const MetadataSchema = Schema.Struct({ ), generationStatus: Schema.optional(Schema.Literal("complete", "failed")), model: ModelIdSchema, + ninaContextSnapshot: Schema.optional(NinaContextSnapshotSchema), + ninaContextTransition: Schema.optional(NinaContextTransitionSchema), tokens: Schema.optional( Schema.Struct({ breakdown: Schema.optional( diff --git a/packages/ai/schema/tools.test.ts b/packages/ai/schema/tools.test.ts index d5b9c8d220..547d8fe2a0 100644 --- a/packages/ai/schema/tools.test.ts +++ b/packages/ai/schema/tools.test.ts @@ -8,7 +8,7 @@ import { asSchema } from "ai"; import dedent from "dedent"; import { describe, expect, it } from "vitest"; -describe("orchestrator tool schemas", () => { +describe("LearningCapability tool schemas", () => { it("uses one compact specialist input contract for every delegation tool", async () => { const jsonSchemas = [ await Promise.resolve(asSchema(nakafaToolInputSchema).jsonSchema), diff --git a/packages/ai/schema/tools.ts b/packages/ai/schema/tools.ts index 6f60ae4d5d..bc5c6f26d2 100644 --- a/packages/ai/schema/tools.ts +++ b/packages/ai/schema/tools.ts @@ -3,9 +3,6 @@ import { createPrompt } from "@repo/ai/prompt/utils"; import { type InferUITools, tool } from "ai"; import { Schema } from "effect"; -export const ToolNameSchema = Schema.Literal("nakafa", "deepResearch", "math"); -export type ToolName = Schema.Schema.Type; - const SpecialistToolInputFields = { request: Schema.NonEmptyString.annotations({ description: createPrompt({ @@ -52,7 +49,7 @@ const SpecialistToolInputFields = { }; /** - * Input schema for the Nakafa orchestrator tool. + * Input schema for the Nakafa LearningCapability tool. */ export const NakafaToolInputSchema = Schema.Struct({ ...SpecialistToolInputFields, @@ -84,7 +81,7 @@ export const NakafaToolInputSchema = Schema.Struct({ }); /** - * Input schema for the deep research orchestrator tool. + * Input schema for the deep research LearningCapability tool. */ export const ResearchToolInputSchema = Schema.Struct({ ...SpecialistToolInputFields, @@ -111,7 +108,7 @@ export const ResearchToolInputSchema = Schema.Struct({ }); /** - * Input schema for the deterministic math orchestrator tool. + * Input schema for the deterministic math LearningCapability tool. */ export const MathToolInputSchema = Schema.Struct({ ...SpecialistToolInputFields, diff --git a/packages/ai/types/agents.ts b/packages/ai/types/agents.ts index 385268af38..2ea704736e 100644 --- a/packages/ai/types/agents.ts +++ b/packages/ai/types/agents.ts @@ -1,79 +1,97 @@ import type { Nakafa } from "@repo/ai/agents/nakafa/service"; -import type { ModelId } from "@repo/ai/config/model"; -import type { SourceReference } from "@repo/ai/lib/source"; +import { ModelIdSchema } from "@repo/ai/config/model"; +import { SourceReferenceSchema } from "@repo/ai/lib/source"; +import { NinaContextPackSchema } from "@repo/ai/nina/memory/pack"; import type { MyUIMessage } from "@repo/ai/types/message"; -import type { PromptUserRole } from "@repo/ai/types/roles"; -import type { - CoverageStatus, - LearningInterest, - LearningPlanItemStatus, - LearningProgramKind, - LearningStage, +import { PromptUserRoleSchema } from "@repo/ai/types/roles"; +import { LocaleSchema } from "@repo/contents/_types/content"; +import { + CoverageStatusSchema, + LearningInterestSchema, + LearningPlanItemStatusSchema, + LearningProgramKeySchema, + LearningProgramKindSchema, + LearningStageSchema, } from "@repo/contents/_types/program/schema"; -import type { Locale } from "@repo/utilities/locales"; import type { UIMessageStreamWriter } from "ai"; +import { Schema } from "effect"; /** Program and plan context agents can use without Convex document coupling. */ -export interface AgentLearningProfile { - interests: readonly LearningInterest[]; - planItems: readonly { - content_id: string; - lensId: string; - position: number; - route?: string; - status: LearningPlanItemStatus; - title?: string; - }[]; - program: { - coverageStatus: CoverageStatus; - key: string; - kind: LearningProgramKind; - title: string; - versionLabel: string; - }; - stage?: LearningStage; -} +export const AgentLearningProfileSchema = Schema.Struct({ + interests: Schema.Array(LearningInterestSchema), + planItems: Schema.Array( + Schema.Struct({ + content_id: Schema.String, + lensId: Schema.String, + position: Schema.Number, + route: Schema.optional(Schema.String), + status: LearningPlanItemStatusSchema, + title: Schema.optional(Schema.String), + }).pipe(Schema.mutable) + ), + program: Schema.Struct({ + coverageStatus: CoverageStatusSchema, + key: LearningProgramKeySchema, + kind: LearningProgramKindSchema, + title: Schema.String, + versionLabel: Schema.String, + }).pipe(Schema.mutable), + stage: Schema.optional(LearningStageSchema), +}).pipe(Schema.mutable); -export interface AgentContext { - currentDate: string; - learningProfile?: AgentLearningProfile; - needsPageFetch: boolean; - slug: string; - url: string; - userRole?: PromptUserRole; - verified: boolean; -} +export type AgentLearningProfile = Schema.Schema.Type< + typeof AgentLearningProfileSchema +>; -/** - * Base parameters shared by all agents. - */ -export interface BaseAgentParams { - context: AgentContext; - locale: Locale; - modelId: ModelId; - writer: UIMessageStreamWriter; -} +/** Per-turn context shared by Nina and specialist agents after harness arbitration. */ +export const AgentContextSchema = Schema.Struct({ + currentDate: Schema.String, + learningProfile: Schema.optional(AgentLearningProfileSchema), + needsPageFetch: Schema.Boolean, + nina: Schema.optional(NinaContextPackSchema), + slug: Schema.String, + url: Schema.String, + userRole: Schema.optional(PromptUserRoleSchema), + verified: Schema.Boolean, +}).pipe(Schema.mutable); -/** - * Parameters for agents that receive a task. - */ -export interface TaskAgentParams extends BaseAgentParams { - task: string; -} +export type AgentContext = Schema.Schema.Type; -/** Parameters for the Nakafa content retrieval subagent. */ -export interface NakafaAgentParams extends TaskAgentParams { - nakafa: Nakafa; -} +/** Schema-derived data passed to task-oriented specialist agents. */ +export const TaskAgentDataSchema = Schema.Struct({ + context: AgentContextSchema, + locale: LocaleSchema, + modelId: ModelIdSchema, + task: Schema.String, +}).pipe(Schema.mutable); -/** Parameters for the external research subagent. */ -export interface ResearchAgentParams extends BaseAgentParams { - sourceReferences: SourceReference[]; - task: string; - toolCallId: string; -} +type TaskAgentData = Schema.Schema.Type; +type SpecialistWriter = UIMessageStreamWriter; -/** - * Agent parameter exports. - */ -export type MathAgentParams = TaskAgentParams; +/** Parameters for the deterministic math specialist Adapter. */ +export type MathAgentParams = TaskAgentData & { + readonly writer: SpecialistWriter; +}; + +/** Parameters for the Nakafa content retrieval specialist Adapter. */ +export type NakafaAgentParams = TaskAgentData & { + readonly nakafa: Nakafa; + readonly writer: SpecialistWriter; +}; + +/** Schema-derived data passed to the external research specialist. */ +export const ResearchAgentDataSchema = Schema.Struct({ + context: AgentContextSchema, + locale: LocaleSchema, + modelId: ModelIdSchema, + sourceReferences: Schema.Array(SourceReferenceSchema), + task: Schema.String, + toolCallId: Schema.String, +}).pipe(Schema.mutable); + +/** Parameters for the external research specialist Adapter. */ +export type ResearchAgentParams = Schema.Schema.Type< + typeof ResearchAgentDataSchema +> & { + readonly writer: SpecialistWriter; +}; diff --git a/packages/ai/types/roles.ts b/packages/ai/types/roles.ts index 8482395c42..572015635e 100644 --- a/packages/ai/types/roles.ts +++ b/packages/ai/types/roles.ts @@ -1,3 +1,5 @@ +import { Schema } from "effect"; + /** * User-role vocabulary accepted by AI prompt context. * @@ -11,4 +13,8 @@ export const promptUserRoles = [ "parent", "administrator", ] as const; -export type PromptUserRole = (typeof promptUserRoles)[number]; + +/** Runtime prompt role contract used by Nina and specialist prompt context. */ +export const PromptUserRoleSchema = Schema.Literal(...promptUserRoles); + +export type PromptUserRole = Schema.Schema.Type; diff --git a/packages/backend/convex/_generated/ai/ai-files.state.json b/packages/backend/convex/_generated/ai/ai-files.state.json index 9e4b19c139..0af28a891c 100644 --- a/packages/backend/convex/_generated/ai/ai-files.state.json +++ b/packages/backend/convex/_generated/ai/ai-files.state.json @@ -2,5 +2,5 @@ "guidelinesHash": "31cdf5763fda9ffee83f538073d80fd995883c95a2bfaf4f6441010f3c391819", "agentsMdSectionHash": "5934f676ea9a332e7cd4a4f64aa23b59d926e9faca026c758d4b1f87d2101cc3", "claudeMdHash": "5934f676ea9a332e7cd4a4f64aa23b59d926e9faca026c758d4b1f87d2101cc3", - "agentSkillsSha": "dc1093bc645f17b17b15943c994289be6453f466" + "agentSkillsSha": "ec1e6baae7d86c7843c22938c75979c016f5c6e9" } diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index 6a04ea9077..11a3a9ed0f 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -64,6 +64,7 @@ import type * as auth_username_request from "../auth/username/request.js"; import type * as chats_actions from "../chats/actions.js"; import type * as chats_assistantResponses_impl from "../chats/assistantResponses/impl.js"; import type * as chats_constants from "../chats/constants.js"; +import type * as chats_context from "../chats/context.js"; import type * as chats_helpers from "../chats/helpers.js"; import type * as chats_messageParts_dbToUi from "../chats/messageParts/dbToUi.js"; import type * as chats_messageParts_providerMetadata from "../chats/messageParts/providerMetadata.js"; @@ -72,6 +73,10 @@ import type * as chats_messageParts_uiToDb from "../chats/messageParts/uiToDb.js import type * as chats_mutations from "../chats/mutations.js"; import type * as chats_queries from "../chats/queries.js"; import type * as chats_read from "../chats/read.js"; +import type * as chats_traces_impl from "../chats/traces/impl.js"; +import type * as chats_traces_mutations from "../chats/traces/mutations.js"; +import type * as chats_traces_queries from "../chats/traces/queries.js"; +import type * as chats_traces_spec from "../chats/traces/spec.js"; import type * as chats_utils from "../chats/utils.js"; import type * as classes_constants from "../classes/constants.js"; import type * as classes_forums_aggregate from "../classes/forums/aggregate.js"; @@ -132,6 +137,7 @@ import type * as contents_analytics_spec from "../contents/analytics/spec.js"; import type * as contents_audioQueue_impl from "../contents/audioQueue/impl.js"; import type * as contents_audioQueue_spec from "../contents/audioQueue/spec.js"; import type * as contents_constants from "../contents/constants.js"; +import type * as contents_context from "../contents/context.js"; import type * as contents_graph from "../contents/graph.js"; import type * as contents_helpers_partitions from "../contents/helpers/partitions.js"; import type * as contents_helpers_popularity from "../contents/helpers/popularity.js"; @@ -144,15 +150,20 @@ import type * as contents_helpers_search_rank from "../contents/helpers/search/r import type * as contents_helpers_search_read from "../contents/helpers/search/read.js"; import type * as contents_helpers_search_result from "../contents/helpers/search/result.js"; import type * as contents_helpers_search_write from "../contents/helpers/search/write.js"; -import type * as contents_helpers_writes from "../contents/helpers/writes.js"; +import type * as contents_metrics_apply from "../contents/metrics/apply.js"; +import type * as contents_metrics_batch from "../contents/metrics/batch.js"; +import type * as contents_metrics_refresh from "../contents/metrics/refresh.js"; import type * as contents_mutations_analytics from "../contents/mutations/analytics.js"; import type * as contents_mutations_audio from "../contents/mutations/audio.js"; +import type * as contents_mutations_popularity from "../contents/mutations/popularity.js"; import type * as contents_mutations_search from "../contents/mutations/search.js"; import type * as contents_mutations_views from "../contents/mutations/views.js"; +import type * as contents_popularity from "../contents/popularity.js"; import type * as contents_queries_audio from "../contents/queries/audio.js"; import type * as contents_queries_recent from "../contents/queries/recent.js"; import type * as contents_queries_runtime from "../contents/queries/runtime.js"; import type * as contents_queries_search from "../contents/queries/search.js"; +import type * as contents_rankings from "../contents/rankings.js"; import type * as contents_runtime_api from "../contents/runtime/api.js"; import type * as contents_runtime_articles from "../contents/runtime/articles.js"; import type * as contents_runtime_catalog from "../contents/runtime/catalog.js"; @@ -164,7 +175,10 @@ import type * as contents_runtime_routes from "../contents/runtime/routes.js"; import type * as contents_runtime_shared from "../contents/runtime/shared.js"; import type * as contents_runtime_spec from "../contents/runtime/spec.js"; import type * as contents_validators from "../contents/validators.js"; +import type * as contents_views_context from "../contents/views/context.js"; import type * as contents_views_impl from "../contents/views/impl.js"; +import type * as contents_views_recent from "../contents/views/recent.js"; +import type * as contents_views_signals from "../contents/views/signals.js"; import type * as contents_views_spec from "../contents/views/spec.js"; import type * as credits_constants from "../credits/constants.js"; import type * as credits_helpers_state from "../credits/helpers/state.js"; @@ -173,7 +187,6 @@ import type * as crons from "../crons.js"; import type * as curriculumLessons_queries from "../curriculumLessons/queries.js"; import type * as curriculumLessons_trending_impl from "../curriculumLessons/trending/impl.js"; import type * as curriculumLessons_trending_spec from "../curriculumLessons/trending/spec.js"; -import type * as curriculumLessons_utils from "../curriculumLessons/utils.js"; import type * as customers_actions_internal from "../customers/actions/internal.js"; import type * as customers_actions_public from "../customers/actions/public.js"; import type * as customers_checkout_impl from "../customers/checkout/impl.js"; @@ -271,6 +284,7 @@ import type * as triggers_comments_commentVotes from "../triggers/comments/comme import type * as triggers_comments_comments from "../triggers/comments/comments.js"; import type * as triggers_contents_exerciseAnswers from "../triggers/contents/exerciseAnswers.js"; import type * as triggers_contents_exerciseAttempts from "../triggers/contents/exerciseAttempts.js"; +import type * as triggers_contents_popularity from "../triggers/contents/popularity.js"; import type * as triggers_contents_views from "../triggers/contents/views.js"; import type * as triggers_forums_postReactions from "../triggers/forums/postReactions.js"; import type * as triggers_forums_posts from "../triggers/forums/posts.js"; @@ -432,6 +446,7 @@ declare const fullApi: ApiFromModules<{ "chats/actions": typeof chats_actions; "chats/assistantResponses/impl": typeof chats_assistantResponses_impl; "chats/constants": typeof chats_constants; + "chats/context": typeof chats_context; "chats/helpers": typeof chats_helpers; "chats/messageParts/dbToUi": typeof chats_messageParts_dbToUi; "chats/messageParts/providerMetadata": typeof chats_messageParts_providerMetadata; @@ -440,6 +455,10 @@ declare const fullApi: ApiFromModules<{ "chats/mutations": typeof chats_mutations; "chats/queries": typeof chats_queries; "chats/read": typeof chats_read; + "chats/traces/impl": typeof chats_traces_impl; + "chats/traces/mutations": typeof chats_traces_mutations; + "chats/traces/queries": typeof chats_traces_queries; + "chats/traces/spec": typeof chats_traces_spec; "chats/utils": typeof chats_utils; "classes/constants": typeof classes_constants; "classes/forums/aggregate": typeof classes_forums_aggregate; @@ -500,6 +519,7 @@ declare const fullApi: ApiFromModules<{ "contents/audioQueue/impl": typeof contents_audioQueue_impl; "contents/audioQueue/spec": typeof contents_audioQueue_spec; "contents/constants": typeof contents_constants; + "contents/context": typeof contents_context; "contents/graph": typeof contents_graph; "contents/helpers/partitions": typeof contents_helpers_partitions; "contents/helpers/popularity": typeof contents_helpers_popularity; @@ -512,15 +532,20 @@ declare const fullApi: ApiFromModules<{ "contents/helpers/search/read": typeof contents_helpers_search_read; "contents/helpers/search/result": typeof contents_helpers_search_result; "contents/helpers/search/write": typeof contents_helpers_search_write; - "contents/helpers/writes": typeof contents_helpers_writes; + "contents/metrics/apply": typeof contents_metrics_apply; + "contents/metrics/batch": typeof contents_metrics_batch; + "contents/metrics/refresh": typeof contents_metrics_refresh; "contents/mutations/analytics": typeof contents_mutations_analytics; "contents/mutations/audio": typeof contents_mutations_audio; + "contents/mutations/popularity": typeof contents_mutations_popularity; "contents/mutations/search": typeof contents_mutations_search; "contents/mutations/views": typeof contents_mutations_views; + "contents/popularity": typeof contents_popularity; "contents/queries/audio": typeof contents_queries_audio; "contents/queries/recent": typeof contents_queries_recent; "contents/queries/runtime": typeof contents_queries_runtime; "contents/queries/search": typeof contents_queries_search; + "contents/rankings": typeof contents_rankings; "contents/runtime/api": typeof contents_runtime_api; "contents/runtime/articles": typeof contents_runtime_articles; "contents/runtime/catalog": typeof contents_runtime_catalog; @@ -532,7 +557,10 @@ declare const fullApi: ApiFromModules<{ "contents/runtime/shared": typeof contents_runtime_shared; "contents/runtime/spec": typeof contents_runtime_spec; "contents/validators": typeof contents_validators; + "contents/views/context": typeof contents_views_context; "contents/views/impl": typeof contents_views_impl; + "contents/views/recent": typeof contents_views_recent; + "contents/views/signals": typeof contents_views_signals; "contents/views/spec": typeof contents_views_spec; "credits/constants": typeof credits_constants; "credits/helpers/state": typeof credits_helpers_state; @@ -541,7 +569,6 @@ declare const fullApi: ApiFromModules<{ "curriculumLessons/queries": typeof curriculumLessons_queries; "curriculumLessons/trending/impl": typeof curriculumLessons_trending_impl; "curriculumLessons/trending/spec": typeof curriculumLessons_trending_spec; - "curriculumLessons/utils": typeof curriculumLessons_utils; "customers/actions/internal": typeof customers_actions_internal; "customers/actions/public": typeof customers_actions_public; "customers/checkout/impl": typeof customers_checkout_impl; @@ -639,6 +666,7 @@ declare const fullApi: ApiFromModules<{ "triggers/comments/comments": typeof triggers_comments_comments; "triggers/contents/exerciseAnswers": typeof triggers_contents_exerciseAnswers; "triggers/contents/exerciseAttempts": typeof triggers_contents_exerciseAttempts; + "triggers/contents/popularity": typeof triggers_contents_popularity; "triggers/contents/views": typeof triggers_contents_views; "triggers/forums/postReactions": typeof triggers_forums_postReactions; "triggers/forums/posts": typeof triggers_forums_posts; @@ -776,4 +804,5 @@ export declare const components: { globalLeaderboard: import("@convex-dev/aggregate/_generated/component.js").ComponentApi<"globalLeaderboard">; forumPostsBySequence: import("@convex-dev/aggregate/_generated/component.js").ComponentApi<"forumPostsBySequence">; forumPostsByAuthorSequence: import("@convex-dev/aggregate/_generated/component.js").ComponentApi<"forumPostsByAuthorSequence">; + learningPopularityRankings: import("@convex-dev/aggregate/_generated/component.js").ComponentApi<"learningPopularityRankings">; }; diff --git a/packages/backend/convex/analytics/capture.test.ts b/packages/backend/convex/analytics/capture.test.ts index 7653cd5a64..710685e169 100644 --- a/packages/backend/convex/analytics/capture.test.ts +++ b/packages/backend/convex/analytics/capture.test.ts @@ -15,6 +15,7 @@ const contentViewProperties = { alignment_id: "alignment:id:articles:example", concept_id: "concept:id:articles:example", content_id: "asset:id:articles:example", + context_key: "canonical", content_type: "article", is_new_view: true, learning_object_id: "lo:id:articles:example", diff --git a/packages/backend/convex/analytics/events.ts b/packages/backend/convex/analytics/events.ts index 2afe5d8957..5cdf641348 100644 --- a/packages/backend/convex/analytics/events.ts +++ b/packages/backend/convex/analytics/events.ts @@ -31,6 +31,7 @@ import { v } from "convex/values"; const optionalNumber = v.optional(v.number()); +/** Analytics event contract accepted by the product capture mutation. */ export const productAnalyticsEventValidator = v.union( v.object({ name: v.literal("user signed up"), @@ -44,6 +45,7 @@ export const productAnalyticsEventValidator = v.union( alignment_id: learningGraphIdentityValidator.fields.alignmentId, concept_id: learningGraphIdentityValidator.fields.conceptId, content_id: graphContentIdValidator, + context_key: v.string(), content_type: contentTypeValidator, is_new_view: v.boolean(), learning_object_id: diff --git a/packages/backend/convex/chats/context.ts b/packages/backend/convex/chats/context.ts new file mode 100644 index 0000000000..2b9eb26844 --- /dev/null +++ b/packages/backend/convex/chats/context.ts @@ -0,0 +1,63 @@ +import { + NINA_CONTEXT_SOURCES, + NINA_CONTEXT_TRANSITION_REASONS, +} from "@repo/ai/nina/memory/pack"; +import { localeValidator } from "@repo/backend/convex/lib/validators/contents"; +import { v } from "convex/values"; +import { literals } from "convex-helpers/validators"; + +const ninaContextSourceValidator = literals(...NINA_CONTEXT_SOURCES); +const ninaContextTransitionReasonValidator = literals( + ...NINA_CONTEXT_TRANSITION_REASONS +); + +/** Convex validator for the page identity stored in Nina message snapshots. */ +export const ninaLearningContextValidator = v.object({ + assetId: v.optional(v.string()), + contentId: v.optional(v.string()), + locale: localeValidator, + materialKey: v.optional(v.string()), + section: v.optional(v.string()), + slug: v.string(), + sourcePath: v.optional(v.string()), + title: v.optional(v.string()), + url: v.string(), + verified: v.boolean(), +}); + +/** Convex validator for verified placement context carried by Nina messages. */ +export const ninaPlacementContextValidator = v.object({ + mode: v.literal("placement"), + nodeKey: v.string(), + parentHref: v.string(), + parentTitle: v.string(), + programKey: v.string(), +}); + +/** Convex validator for specialist permissions captured with a Nina turn. */ +export const ninaToolContextValidator = v.object({ + allowDeepResearch: v.boolean(), + allowMath: v.boolean(), + allowNakafa: v.boolean(), + allowPageFetch: v.boolean(), + evidenceScope: v.union( + v.literal("verified-page"), + v.literal("general-learning") + ), +}); + +/** Convex validator for the compact Nina context snapshot on chat messages. */ +export const ninaContextSnapshotValidator = v.object({ + capturedAt: v.string(), + learning: ninaLearningContextValidator, + placement: v.optional(ninaPlacementContextValidator), + source: ninaContextSourceValidator, + tools: ninaToolContextValidator, +}); + +/** Convex validator for explicit Nina context transition metadata. */ +export const ninaContextTransitionValidator = v.object({ + fromContextKey: v.optional(v.string()), + reason: ninaContextTransitionReasonValidator, + toContextKey: v.string(), +}); diff --git a/packages/backend/convex/chats/helpers.ts b/packages/backend/convex/chats/helpers.ts index 113fc4c95c..88a655baf5 100644 --- a/packages/backend/convex/chats/helpers.ts +++ b/packages/backend/convex/chats/helpers.ts @@ -60,6 +60,8 @@ export async function deletePartsForMessageBatch( /** * Delete one bounded transcript batch from a creation time onward. * This supports message regeneration without loading an unbounded mutation. + * Reads one sentinel row past the supported batch so exact-size rewrites are + * accepted while oversized rewrites are rejected transactionally. */ export async function deleteMessageBatchFromPoint( ctx: MutationCtx, @@ -71,13 +73,16 @@ export async function deleteMessageBatchFromPoint( .withIndex("by_chatId", (q) => q.eq("chatId", chatId).gte("_creationTime", fromCreationTime) ) - .take(CHAT_TRANSCRIPT_REWRITE_MESSAGE_BATCH_SIZE); + .take(CHAT_TRANSCRIPT_REWRITE_MESSAGE_BATCH_SIZE + 1); if (messages.length === 0) { return { hasMore: false }; } - for (const message of messages) { + for (const message of messages.slice( + 0, + CHAT_TRANSCRIPT_REWRITE_MESSAGE_BATCH_SIZE + )) { const partsBatch = await deletePartsForMessageBatch(ctx, message._id); if (partsBatch.hasMore) { @@ -88,7 +93,7 @@ export async function deleteMessageBatchFromPoint( } return { - hasMore: messages.length === CHAT_TRANSCRIPT_REWRITE_MESSAGE_BATCH_SIZE, + hasMore: messages.length > CHAT_TRANSCRIPT_REWRITE_MESSAGE_BATCH_SIZE, }; } diff --git a/packages/backend/convex/chats/mutations.test.ts b/packages/backend/convex/chats/mutations.test.ts index 959c39c111..944c09ad21 100644 --- a/packages/backend/convex/chats/mutations.test.ts +++ b/packages/backend/convex/chats/mutations.test.ts @@ -2,6 +2,9 @@ import posthogTest from "@posthog/convex/test"; import { chatResponseFailureCode } from "@repo/ai/config/generation"; import { getModelCreditCost, ModelIdSchema } from "@repo/ai/config/model"; import { api, internal } from "@repo/backend/convex/_generated/api"; +import type { Id } from "@repo/backend/convex/_generated/dataModel"; +import type { MutationCtx } from "@repo/backend/convex/_generated/server"; +import { CHAT_TRANSCRIPT_REWRITE_MESSAGE_BATCH_SIZE } from "@repo/backend/convex/chats/constants"; import schema from "@repo/backend/convex/schema"; import { createConvexTestWithBetterAuth, @@ -15,6 +18,22 @@ const NOW = Date.UTC(2026, 3, 2, 12, 0, 0); const liteModel = ModelIdSchema.make("nakafa-lite"); const liteCreditCost = getModelCreditCost(liteModel); +/** Inserts generated tail messages used to exercise bounded transcript rewrites. */ +async function insertGeneratedTailMessages( + ctx: MutationCtx, + chatId: Id<"chats">, + count: number +) { + for (let index = 0; index < count; index += 1) { + await ctx.db.insert("messages", { + chatId, + identifier: `assistant-tail-${index}`, + modelId: "nakafa-lite", + role: "assistant", + }); + } +} + describe("chats/mutations", () => { beforeEach(() => { vi.setSystemTime(new Date(NOW)); @@ -307,4 +326,132 @@ describe("chats/mutations", () => { }), ]); }); + + it("atomically replaces an existing user message and its generated tail", async () => { + const t = createConvexTestWithBetterAuth(); + posthogTest.register(t); + const identity = await t.mutation( + async (ctx) => + await seedAuthenticatedUser(ctx, { + now: NOW, + suffix: "rewrite-owner", + }) + ); + const owner = t.withIdentity({ + sessionId: identity.sessionId, + subject: identity.authUserId, + }); + const { chatId } = await owner.mutation( + api.chats.mutations.createChatWithMessage, + { + type: "study", + message: { + role: "user", + identifier: "user-rewrite", + modelId: "nakafa-lite", + }, + parts: [], + } + ); + + await t.mutation(internal.chats.mutations.saveAssistantResponse, { + userId: identity.userId, + message: { + chatId, + role: "assistant", + identifier: "assistant-tail", + modelId: "nakafa-lite", + }, + parts: [], + }); + + const replacement = await owner.mutation(api.chats.mutations.saveMessage, { + message: { + chatId, + role: "user", + identifier: "user-rewrite", + modelId: "nakafa-lite", + }, + parts: [], + }); + const messages = await t.query( + async (ctx) => + await ctx.db + .query("messages") + .withIndex("by_chatId", (q) => q.eq("chatId", chatId)) + .collect() + ); + + expect(messages).toEqual([ + expect.objectContaining({ + _id: replacement.messageId, + chatId, + identifier: "user-rewrite", + role: "user", + }), + ]); + }); + + it("allows transcript rewrites that exactly fill the bounded delete batch", async () => { + const t = createConvexTestWithBetterAuth(); + posthogTest.register(t); + const identity = await t.mutation( + async (ctx) => + await seedAuthenticatedUser(ctx, { + now: NOW, + suffix: "exact-rewrite-owner", + }) + ); + const owner = t.withIdentity({ + sessionId: identity.sessionId, + subject: identity.authUserId, + }); + const { chatId } = await owner.mutation( + api.chats.mutations.createChatWithMessage, + { + type: "study", + message: { + role: "user", + identifier: "user-rewrite-exact", + modelId: "nakafa-lite", + }, + parts: [], + } + ); + + await t.mutation( + async (ctx) => + await insertGeneratedTailMessages( + ctx, + chatId, + CHAT_TRANSCRIPT_REWRITE_MESSAGE_BATCH_SIZE - 1 + ) + ); + + const replacement = await owner.mutation(api.chats.mutations.saveMessage, { + message: { + chatId, + role: "user", + identifier: "user-rewrite-exact", + modelId: "nakafa-lite", + }, + parts: [], + }); + const messages = await t.query( + async (ctx) => + await ctx.db + .query("messages") + .withIndex("by_chatId", (q) => q.eq("chatId", chatId)) + .collect() + ); + + expect(messages).toEqual([ + expect.objectContaining({ + _id: replacement.messageId, + chatId, + identifier: "user-rewrite-exact", + role: "user", + }), + ]); + }); }); diff --git a/packages/backend/convex/chats/mutations.ts b/packages/backend/convex/chats/mutations.ts index 88a991dd6d..5e1713b06a 100644 --- a/packages/backend/convex/chats/mutations.ts +++ b/packages/backend/convex/chats/mutations.ts @@ -6,6 +6,7 @@ import { } from "@repo/backend/convex/chats/assistantResponses/impl"; import { deleteMessageBatchFromPoint, + getMessageByIdentifier, insertParts, verifyChatOwnership, } from "@repo/backend/convex/chats/helpers"; @@ -104,7 +105,7 @@ export const updateChatVisibility = mutation({ }, }); -/** Persist one user message and its parts after any transcript rewrite is complete. */ +/** Persist one user message and its parts, atomically replacing a rewrite tail. */ export const saveMessage = mutation({ args: { message: tables.messages.validator, @@ -125,12 +126,35 @@ export const saveMessage = mutation({ await verifyChatOwnership(ctx, message.chatId, user.appUser._id); + const existingMessage = await getMessageByIdentifier( + ctx, + message.chatId, + message.identifier + ); + + if (existingMessage) { + const deleteResult = await deleteMessageBatchFromPoint( + ctx, + message.chatId, + existingMessage._creationTime + ); + + if (deleteResult.hasMore) { + throw new ConvexError({ + code: "CHAT_USER_MESSAGE_REWRITE_EXCEEDED", + message: "User message rewrite exceeded the supported batch size.", + }); + } + } + // modelId stored server-side so clients cannot spoof credit calculations const messageId = await ctx.db.insert("messages", { chatId: message.chatId, role: message.role, identifier: message.identifier, modelId: message.modelId, + ninaContextSnapshot: message.ninaContextSnapshot, + ninaContextTransition: message.ninaContextTransition, }); const partIds = await insertParts(ctx, messageId, parts); @@ -139,22 +163,6 @@ export const saveMessage = mutation({ }, }); -/** Delete one bounded transcript-rewrite batch before re-saving a message. */ -export const deleteMessageBatch = mutation({ - args: { - chatId: vv.id("chats"), - fromCreationTime: v.number(), - }, - returns: v.object({ hasMore: v.boolean() }), - handler: async (ctx, args) => { - const user = await requireAuth(ctx); - - await verifyChatOwnership(ctx, args.chatId, user.appUser._id); - - return deleteMessageBatchFromPoint(ctx, args.chatId, args.fromCreationTime); - }, -}); - /** Atomically creates a chat with its first message and parts. */ export const createChatWithMessage = mutation({ args: { @@ -192,6 +200,8 @@ export const createChatWithMessage = mutation({ role: args.message.role, identifier: args.message.identifier, modelId: args.message.modelId, + ninaContextSnapshot: args.message.ninaContextSnapshot, + ninaContextTransition: args.message.ninaContextTransition, }); const partIds = await insertParts(ctx, messageId, args.parts); @@ -289,6 +299,8 @@ export const saveAssistantResponse = internalMutation({ totalTokens: message.totalTokens, credits: creditUsage?.credits, generationStatus: "complete", + ninaContextSnapshot: message.ninaContextSnapshot, + ninaContextTransition: message.ninaContextTransition, }); const partIds = await insertParts(ctx, messageId, parts); diff --git a/packages/backend/convex/chats/queries.test.ts b/packages/backend/convex/chats/queries.test.ts index fb063b665c..bfc3b3bb95 100644 --- a/packages/backend/convex/chats/queries.test.ts +++ b/packages/backend/convex/chats/queries.test.ts @@ -1,12 +1,37 @@ import { api } from "@repo/backend/convex/_generated/api"; +import type { ninaContextSnapshotValidator } from "@repo/backend/convex/chats/context"; import { createConvexTestWithBetterAuth, seedAuthenticatedUser, } from "@repo/backend/convex/test.helpers"; +import type { Infer } from "convex/values"; import { describe, expect, it } from "vitest"; const NOW = Date.UTC(2026, 4, 13, 12, 0, 0); +/** Builds a compact Nina context snapshot for query behavior tests. */ +function testNinaContext( + slug: string +): Infer { + return { + capturedAt: new Date(NOW).toISOString(), + learning: { + locale: "id", + slug, + url: `https://nakafa.com/id/${slug}`, + verified: true, + }, + source: "current-page", + tools: { + allowDeepResearch: true, + allowMath: true, + allowNakafa: true, + allowPageFetch: true, + evidenceScope: "verified-page", + }, + }; +} + describe("chats/queries", () => { it("allows signed-out viewers to read public chat details", async () => { const t = createConvexTestWithBetterAuth(); @@ -191,4 +216,78 @@ describe("chats/queries", () => { expect.objectContaining({ title: "Viewer chat" }), ]); }); + + it("resolves pinned Nina context from the transcript retained after a rewrite", async () => { + const t = createConvexTestWithBetterAuth(); + const identity = await t.mutation(async (ctx) => { + const user = await seedAuthenticatedUser(ctx, { + now: NOW, + suffix: "pinned-context", + }); + const chatId = await ctx.db.insert("chats", { + title: "Pinned context", + type: "study", + updatedAt: NOW, + userId: user.userId, + visibility: "private", + }); + + return { chatId, user }; + }); + const owner = t.withIdentity({ + sessionId: identity.user.sessionId, + subject: identity.user.authUserId, + }); + const retainedContext = testNinaContext( + "materi/matematika/integral/jumlahan-riemann" + ); + const deletedTailContext = testNinaContext( + "materi/fisika/mekanika/hukum-newton" + ); + + await t.mutation(async (ctx) => { + await ctx.db.insert("messages", { + chatId: identity.chatId, + identifier: "retained-anchor", + modelId: "nakafa-lite", + ninaContextSnapshot: retainedContext, + role: "user", + }); + }); + await t.mutation(async (ctx) => { + await ctx.db.insert("messages", { + chatId: identity.chatId, + identifier: "rewrite-target", + modelId: "nakafa-lite", + role: "user", + }); + }); + await t.mutation(async (ctx) => { + await ctx.db.insert("messages", { + chatId: identity.chatId, + identifier: "deleted-tail", + modelId: "nakafa-lite", + ninaContextSnapshot: deletedTailContext, + role: "assistant", + }); + }); + + const pinnedForRewrite = await owner.query( + api.chats.queries.getPinnedNinaContextForTurn, + { + chatId: identity.chatId, + messageIdentifier: "rewrite-target", + } + ); + const pinnedForContinuation = await owner.query( + api.chats.queries.getPinnedNinaContextForTurn, + { + chatId: identity.chatId, + messageIdentifier: "new-message", + } + ); + + expect(pinnedForRewrite).toEqual(retainedContext); + expect(pinnedForContinuation).toEqual(deletedTailContext); + }); }); diff --git a/packages/backend/convex/chats/queries.ts b/packages/backend/convex/chats/queries.ts index 378f69423d..908d6f7419 100644 --- a/packages/backend/convex/chats/queries.ts +++ b/packages/backend/convex/chats/queries.ts @@ -1,8 +1,8 @@ +import type { Id } from "@repo/backend/convex/_generated/dataModel"; +import type { QueryCtx } from "@repo/backend/convex/_generated/server"; import { query } from "@repo/backend/convex/_generated/server"; -import { - getMessageByIdentifier, - verifyChatOwnership, -} from "@repo/backend/convex/chats/helpers"; +import { ninaContextSnapshotValidator } from "@repo/backend/convex/chats/context"; +import { getMessageByIdentifier } from "@repo/backend/convex/chats/helpers"; import { hydrateMessagePage } from "@repo/backend/convex/chats/read"; import { chatTypeValidator, @@ -10,16 +10,42 @@ import { paginatedChatsValidator, paginatedMessagesValidator, } from "@repo/backend/convex/chats/schema"; -import { - getOptionalAppUser, - requireAuth, -} from "@repo/backend/convex/lib/helpers/auth"; +import { getOptionalAppUser } from "@repo/backend/convex/lib/helpers/auth"; import { requireChatAccess } from "@repo/backend/convex/lib/helpers/chat"; import { vv } from "@repo/backend/convex/lib/validators/vv"; import { paginationOptsValidator } from "convex/server"; import { ConvexError, v } from "convex/values"; import { nullable } from "convex-helpers/validators"; +const LATEST_NINA_CONTEXT_SCAN_LIMIT = 20; + +/** + * Reads the bounded newest messages that can pin Nina context for a turn. + * When a rewrite target exists, messages at or after that point are excluded + * because the save mutation will delete them before inserting the replacement. + */ +function loadPinnedContextMessages( + ctx: QueryCtx, + chatId: Id<"chats">, + beforeCreationTime?: number +) { + if (beforeCreationTime === undefined) { + return ctx.db + .query("messages") + .withIndex("by_chatId", (q) => q.eq("chatId", chatId)) + .order("desc") + .take(LATEST_NINA_CONTEXT_SCAN_LIMIT); + } + + return ctx.db + .query("messages") + .withIndex("by_chatId", (q) => + q.eq("chatId", chatId).lt("_creationTime", beforeCreationTime) + ) + .order("desc") + .take(LATEST_NINA_CONTEXT_SCAN_LIMIT); +} + /** * Get a chat by its ID. * Public chats are readable by signed-in and signed-out viewers. @@ -224,6 +250,50 @@ export const getChatTitle = query({ }, }); +/** + * Returns the newest stored Nina context snapshot that survives the next turn. + * Existing message identifiers are treated as transcript rewrites, so context + * from the tail that will be deleted is not reused as pinned chat context. + */ +export const getPinnedNinaContextForTurn = query({ + args: { + chatId: vv.id("chats"), + messageIdentifier: v.string(), + }, + returns: nullable(ninaContextSnapshotValidator), + handler: async (ctx, args) => { + const viewer = await getOptionalAppUser(ctx); + const viewerUserId = viewer?.appUser._id ?? null; + + const chat = await ctx.db.get(args.chatId); + + if (!chat) { + throw new ConvexError({ + code: "CHAT_NOT_FOUND", + message: `Chat not found for chatId: ${args.chatId}`, + }); + } + + requireChatAccess(chat.userId, viewerUserId, chat.visibility); + + const existingMessage = await getMessageByIdentifier( + ctx, + args.chatId, + args.messageIdentifier + ); + const messages = await loadPinnedContextMessages( + ctx, + args.chatId, + existingMessage?._creationTime + ); + + return ( + messages.find((message) => message.ninaContextSnapshot) + ?.ninaContextSnapshot ?? null + ); + }, +}); + /** Return one transcript page ordered from newest to oldest. */ export const loadMessagesPage = query({ args: { @@ -258,38 +328,3 @@ export const loadMessagesPage = query({ }; }, }); - -/** Find one persisted chat message by the UI message identifier. */ -export const getMessageMatch = query({ - args: { - chatId: vv.id("chats"), - identifier: v.string(), - }, - returns: v.union( - v.null(), - v.object({ - creationTime: v.number(), - messageId: vv.id("messages"), - }) - ), - handler: async (ctx, args) => { - const user = await requireAuth(ctx); - - await verifyChatOwnership(ctx, args.chatId, user.appUser._id); - - const message = await getMessageByIdentifier( - ctx, - args.chatId, - args.identifier - ); - - if (!message) { - return null; - } - - return { - creationTime: message._creationTime, - messageId: message._id, - }; - }, -}); diff --git a/packages/backend/convex/chats/schema.ts b/packages/backend/convex/chats/schema.ts index e65b9f9739..0f55966ce9 100644 --- a/packages/backend/convex/chats/schema.ts +++ b/packages/backend/convex/chats/schema.ts @@ -1,5 +1,10 @@ import { CHAT_GENERATION_FAILURE_CODES } from "@repo/ai/config/generation"; import { MODEL_IDS } from "@repo/ai/config/model"; +import { + ninaContextSnapshotValidator, + ninaContextTransitionValidator, +} from "@repo/backend/convex/chats/context"; +import { capabilityTraceValidator } from "@repo/backend/convex/chats/traces/spec"; import { contentSearchInputValidator, contentSearchRefValidator, @@ -77,6 +82,7 @@ export const messageGenerationErrorCodeValidator = literals( ...CHAT_GENERATION_FAILURE_CODES ); +/** Stored chat message contract, including optional Nina context metadata. */ export const messageValidator = v.object({ identifier: v.string(), chatId: v.id("chats"), @@ -88,6 +94,8 @@ export const messageValidator = v.object({ modelId: modelIdValidator, generationStatus: v.optional(messageGenerationStatusValidator), generationErrorCode: v.optional(messageGenerationErrorCodeValidator), + ninaContextSnapshot: v.optional(ninaContextSnapshotValidator), + ninaContextTransition: v.optional(ninaContextTransitionValidator), }); /** @@ -119,7 +127,7 @@ export const partTypeValidator = literals( "reasoning", "file", "step-start", - // Orchestrator tools + // Nina LearningCapability tools "tool-nakafa", "tool-deepResearch", "tool-math", @@ -461,7 +469,7 @@ export const partValidator = v.object({ toolCallProviderMetadata: providerMetadataValidator, toolResultProviderMetadata: providerMetadataValidator, - // Orchestrator tool fields + // Nina LearningCapability tool fields toolNakafaInput: v.optional(nakafaToolInputValidator), toolNakafaOutput: v.optional(v.string()), toolMathInput: v.optional(mathToolInputValidator), @@ -544,6 +552,17 @@ export const tables = { "messageId", "order", ]), + + ninaCapabilityTraces: defineTable(capabilityTraceValidator) + .index("by_chatId_and_startedAt", ["chatId", "startedAt"]) + .index("by_chatId_and_responseMessageIdentifier_and_startedAt", [ + "chatId", + "responseMessageIdentifier", + "startedAt", + ]) + .index("by_capability_and_startedAt", ["capability", "startedAt"]) + .index("by_status_and_startedAt", ["status", "startedAt"]) + .index("by_expiresAt", ["expiresAt"]), }; export default tables; diff --git a/packages/backend/convex/chats/traces/impl.ts b/packages/backend/convex/chats/traces/impl.ts new file mode 100644 index 0000000000..c296ef7c39 --- /dev/null +++ b/packages/backend/convex/chats/traces/impl.ts @@ -0,0 +1,117 @@ +import { internal } from "@repo/backend/convex/_generated/api"; +import type { Doc } from "@repo/backend/convex/_generated/dataModel"; +import type { + MutationCtx, + QueryCtx, +} from "@repo/backend/convex/_generated/server"; +import { verifyChatOwnership } from "@repo/backend/convex/chats/helpers"; +import { + CAPABILITY_TRACE_BATCH_SIZE, + CAPABILITY_TRACE_RETENTION_MS, + type CapabilityTraceInput, + type DeleteExpiredCapabilityTracesArgs, + type ListCapabilityTracesArgs, +} from "@repo/backend/convex/chats/traces/spec"; +import { getOptionalAppUser } from "@repo/backend/convex/lib/helpers/auth"; +import { ConvexError } from "convex/values"; + +const defaultTraceReadLimit = 20; +const maxTraceReadLimit = 100; + +/** Resolves the authenticated owner for one trace operation. */ +async function requireTraceOwner(ctx: MutationCtx | QueryCtx) { + const user = await getOptionalAppUser(ctx); + + if (!user) { + throw new ConvexError({ + code: "UNAUTHORIZED", + message: "Sign in is required to read or write Nina capability traces.", + }); + } + + return user.appUser; +} + +/** Persists one bounded operational trace for a Nina LearningCapability call. */ +export async function saveCapabilityTrace( + ctx: MutationCtx, + chatId: Doc<"chats">["_id"], + trace: CapabilityTraceInput +) { + const user = await requireTraceOwner(ctx); + + await verifyChatOwnership(ctx, chatId, user._id); + + return await ctx.db.insert("ninaCapabilityTraces", { + ...trace, + chatId, + expiresAt: trace.endedAt + CAPABILITY_TRACE_RETENTION_MS, + status: trace.evidence.status, + userId: user._id, + }); +} + +/** Lists recent trace summaries for one owned chat using bounded indexed reads. */ +export async function listCapabilityTraces( + ctx: QueryCtx, + args: ListCapabilityTracesArgs +) { + const user = await requireTraceOwner(ctx); + const limit = Math.min( + args.limit ?? defaultTraceReadLimit, + maxTraceReadLimit + ); + + await verifyChatOwnership(ctx, args.chatId, user._id); + + const responseMessageIdentifier = args.responseMessageIdentifier; + + if (responseMessageIdentifier) { + return await ctx.db + .query("ninaCapabilityTraces") + .withIndex("by_chatId_and_responseMessageIdentifier_and_startedAt", (q) => + q + .eq("chatId", args.chatId) + .eq("responseMessageIdentifier", responseMessageIdentifier) + ) + .order("desc") + .take(limit); + } + + return await ctx.db + .query("ninaCapabilityTraces") + .withIndex("by_chatId_and_startedAt", (q) => q.eq("chatId", args.chatId)) + .order("desc") + .take(limit); +} + +/** Deletes one bounded page of expired derived trace summaries. */ +export async function deleteExpiredCapabilityTraces( + ctx: MutationCtx, + args: DeleteExpiredCapabilityTracesArgs +) { + const expired = await ctx.db + .query("ninaCapabilityTraces") + .withIndex("by_expiresAt", (q) => q.lte("expiresAt", args.now)) + .take(CAPABILITY_TRACE_BATCH_SIZE + 1); + const page = expired.slice(0, CAPABILITY_TRACE_BATCH_SIZE); + + for (const trace of page) { + await ctx.db.delete(trace._id); + } + + const hasMore = expired.length > CAPABILITY_TRACE_BATCH_SIZE; + + if (hasMore) { + await ctx.scheduler.runAfter( + 0, + internal.chats.traces.mutations.deleteExpiredBatch, + { now: args.now } + ); + } + + return { + deleted: page.length, + hasMore, + }; +} diff --git a/packages/backend/convex/chats/traces/mutations.ts b/packages/backend/convex/chats/traces/mutations.ts new file mode 100644 index 0000000000..3a7d7bcf38 --- /dev/null +++ b/packages/backend/convex/chats/traces/mutations.ts @@ -0,0 +1,37 @@ +import { + deleteExpiredCapabilityTraces, + saveCapabilityTrace, +} from "@repo/backend/convex/chats/traces/impl"; +import { + capabilityTraceInputValidator, + deleteExpiredCapabilityTracesArgs, + deleteExpiredCapabilityTracesResultValidator, +} from "@repo/backend/convex/chats/traces/spec"; +import { internalMutation, mutation } from "@repo/backend/convex/functions"; +import { v } from "convex/values"; + +/** Saves one owner-scoped Nina LearningCapability trace summary. */ +export const save = mutation({ + args: { + chatId: v.id("chats"), + trace: capabilityTraceInputValidator, + }, + returns: v.id("ninaCapabilityTraces"), + handler: async (ctx, args) => + await saveCapabilityTrace(ctx, args.chatId, args.trace), +}); + +/** Deletes a bounded page of expired derived trace summaries. */ +export const deleteExpiredBatch = internalMutation({ + args: deleteExpiredCapabilityTracesArgs, + returns: deleteExpiredCapabilityTracesResultValidator, + handler: async (ctx, args) => await deleteExpiredCapabilityTraces(ctx, args), +}); + +/** Starts the scheduled retention sweep for expired capability trace summaries. */ +export const sweepExpired = internalMutation({ + args: {}, + returns: deleteExpiredCapabilityTracesResultValidator, + handler: async (ctx) => + await deleteExpiredCapabilityTraces(ctx, { now: Date.now() }), +}); diff --git a/packages/backend/convex/chats/traces/queries.test.ts b/packages/backend/convex/chats/traces/queries.test.ts new file mode 100644 index 0000000000..e7c2535d28 --- /dev/null +++ b/packages/backend/convex/chats/traces/queries.test.ts @@ -0,0 +1,185 @@ +import { api, internal } from "@repo/backend/convex/_generated/api"; +import { CAPABILITY_TRACE_BATCH_SIZE } from "@repo/backend/convex/chats/traces/spec"; +import { + createConvexTestWithBetterAuth, + seedAuthenticatedUser, +} from "@repo/backend/convex/test.helpers"; +import { describe, expect, it } from "vitest"; + +const NOW = Date.UTC(2026, 5, 22, 12, 0, 0); + +describe("chats/traces", () => { + it("persists bounded owner-scoped capability traces", async () => { + const t = createConvexTestWithBetterAuth(); + const identity = await t.mutation(async (ctx) => { + const user = await seedAuthenticatedUser(ctx, { + now: NOW, + suffix: "trace-owner", + }); + const chatId = await ctx.db.insert("chats", { + title: "Trace chat", + type: "study", + updatedAt: NOW, + userId: user.userId, + visibility: "private", + }); + + return { ...user, chatId }; + }); + const owner = t.withIdentity({ + sessionId: identity.sessionId, + subject: identity.authUserId, + }); + + const traceId = await owner.mutation(api.chats.traces.mutations.save, { + chatId: identity.chatId, + trace: { + capability: "math", + durationMs: 8, + endedAt: NOW + 8, + evidence: { + capability: "math", + status: "available", + summary: "checked derivative evidence", + }, + responseMessageIdentifier: "response-1", + startedAt: NOW, + toolCallId: "tool-1", + }, + }); + const traces = await owner.query(api.chats.traces.queries.list, { + chatId: identity.chatId, + responseMessageIdentifier: "response-1", + }); + + expect(traces).toEqual([ + expect.objectContaining({ + _id: traceId, + capability: "math", + responseMessageIdentifier: "response-1", + status: "available", + userId: identity.userId, + }), + ]); + expect(traces[0]?.evidence.summary).toBe("checked derivative evidence"); + }); + + it("deletes only expired derived trace summaries in bounded batches", async () => { + const t = createConvexTestWithBetterAuth(); + const { expiredId, retainedId } = await t.mutation(async (ctx) => { + const user = await seedAuthenticatedUser(ctx, { + now: NOW, + suffix: "trace-cleanup", + }); + const chatId = await ctx.db.insert("chats", { + title: "Trace cleanup", + type: "study", + updatedAt: NOW, + userId: user.userId, + visibility: "private", + }); + const expiredId = await ctx.db.insert("ninaCapabilityTraces", { + capability: "nakafa", + chatId, + durationMs: 4, + endedAt: NOW, + evidence: { + capability: "nakafa", + status: "available", + summary: "retrieved lesson summary", + }, + expiresAt: NOW - 1, + responseMessageIdentifier: "response-1", + startedAt: NOW - 4, + status: "available", + userId: user.userId, + }); + const retainedId = await ctx.db.insert("ninaCapabilityTraces", { + capability: "nakafa", + chatId, + durationMs: 4, + endedAt: NOW, + evidence: { + capability: "nakafa", + status: "available", + summary: "retrieved lesson summary", + }, + expiresAt: NOW + 1, + responseMessageIdentifier: "response-2", + startedAt: NOW - 4, + status: "available", + userId: user.userId, + }); + + return { expiredId, retainedId }; + }); + + const result = await t.mutation( + internal.chats.traces.mutations.deleteExpiredBatch, + { now: NOW } + ); + const rows = await t.query( + async (ctx) => await ctx.db.query("ninaCapabilityTraces").collect() + ); + + expect(result).toEqual({ deleted: 1, hasMore: false }); + expect(rows.map((row) => row._id)).toEqual([retainedId]); + expect(rows.map((row) => row._id)).not.toContain(expiredId); + }); + + it("schedules a follow-up cleanup page when expired traces exceed one batch", async () => { + const t = createConvexTestWithBetterAuth(); + + await t.mutation(async (ctx) => { + const user = await seedAuthenticatedUser(ctx, { + now: NOW, + suffix: "trace-cleanup-page", + }); + const chatId = await ctx.db.insert("chats", { + title: "Trace cleanup page", + type: "study", + updatedAt: NOW, + userId: user.userId, + visibility: "private", + }); + + for (let index = 0; index < CAPABILITY_TRACE_BATCH_SIZE + 1; index++) { + await ctx.db.insert("ninaCapabilityTraces", { + capability: "nakafa", + chatId, + durationMs: 4, + endedAt: NOW, + evidence: { + capability: "nakafa", + status: "available", + summary: `retrieved lesson summary ${index}`, + }, + expiresAt: NOW - 1, + responseMessageIdentifier: `response-${index}`, + startedAt: NOW - 4, + status: "available", + userId: user.userId, + }); + } + }); + + const result = await t.mutation( + internal.chats.traces.mutations.deleteExpiredBatch, + { now: NOW } + ); + const scheduledJobs = await t.query( + async (ctx) => await ctx.db.system.query("_scheduled_functions").collect() + ); + + expect(result).toEqual({ + deleted: CAPABILITY_TRACE_BATCH_SIZE, + hasMore: true, + }); + expect(scheduledJobs).toEqual([ + expect.objectContaining({ + args: [{ now: NOW }], + name: expect.stringContaining("deleteExpiredBatch"), + }), + ]); + }); +}); diff --git a/packages/backend/convex/chats/traces/queries.ts b/packages/backend/convex/chats/traces/queries.ts new file mode 100644 index 0000000000..c1c4df8940 --- /dev/null +++ b/packages/backend/convex/chats/traces/queries.ts @@ -0,0 +1,14 @@ +import { query } from "@repo/backend/convex/_generated/server"; +import { listCapabilityTraces } from "@repo/backend/convex/chats/traces/impl"; +import { + capabilityTraceRecordValidator, + listCapabilityTracesArgs, +} from "@repo/backend/convex/chats/traces/spec"; +import { v } from "convex/values"; + +/** Lists bounded Nina LearningCapability trace summaries for an owned chat. */ +export const list = query({ + args: listCapabilityTracesArgs, + returns: v.array(capabilityTraceRecordValidator), + handler: async (ctx, args) => await listCapabilityTraces(ctx, args), +}); diff --git a/packages/backend/convex/chats/traces/spec.ts b/packages/backend/convex/chats/traces/spec.ts new file mode 100644 index 0000000000..5fd24b996d --- /dev/null +++ b/packages/backend/convex/chats/traces/spec.ts @@ -0,0 +1,82 @@ +import { + EVIDENCE_STATUS_VALUES, + LEARNING_CAPABILITY_NAME_VALUES, +} from "@repo/ai/nina/capability/spec"; +import { type Infer, v } from "convex/values"; +import { literals } from "convex-helpers/validators"; + +export const CAPABILITY_TRACE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; +export const CAPABILITY_TRACE_BATCH_SIZE = 100; + +const capabilityNameValidator = literals(...LEARNING_CAPABILITY_NAME_VALUES); +const evidenceStatusValidator = literals(...EVIDENCE_STATUS_VALUES); + +/** Convex-owned validator for a bounded LearningCapability evidence summary. */ +export const evidenceEnvelopeValidator = v.object({ + capability: capabilityNameValidator, + limitations: v.optional(v.array(v.string())), + refs: v.optional(v.array(v.string())), + status: evidenceStatusValidator, + summary: v.string(), +}); + +/** App-provided CapabilityTrace payload before Convex attaches ownership data. */ +export const capabilityTraceInputValidator = v.object({ + capability: capabilityNameValidator, + durationMs: v.number(), + endedAt: v.number(), + evidence: evidenceEnvelopeValidator, + responseMessageIdentifier: v.string(), + startedAt: v.number(), + toolCallId: v.optional(v.string()), +}); + +/** Persisted operational trace row for one LearningCapability execution. */ +export const capabilityTraceValidator = v.object({ + ...capabilityTraceInputValidator.fields, + chatId: v.id("chats"), + expiresAt: v.number(), + status: evidenceStatusValidator, + userId: v.id("users"), +}); + +/** Persisted trace document shape returned by bounded owner-scoped reads. */ +export const capabilityTraceRecordValidator = v.object({ + ...capabilityTraceValidator.fields, + _creationTime: v.number(), + _id: v.id("ninaCapabilityTraces"), +}); + +/** Arguments for owner-scoped support reads of recent capability traces. */ +export const listCapabilityTracesArgs = { + chatId: v.id("chats"), + limit: v.optional(v.number()), + responseMessageIdentifier: v.optional(v.string()), +}; + +export const listCapabilityTracesArgsValidator = v.object( + listCapabilityTracesArgs +); + +/** Arguments for bounded operational retention cleanup. */ +export const deleteExpiredCapabilityTracesArgs = { + now: v.number(), +}; + +export const deleteExpiredCapabilityTracesArgsValidator = v.object( + deleteExpiredCapabilityTracesArgs +); + +/** Result returned by one bounded trace retention cleanup page. */ +export const deleteExpiredCapabilityTracesResultValidator = v.object({ + deleted: v.number(), + hasMore: v.boolean(), +}); + +export type CapabilityTraceInput = Infer; +export type ListCapabilityTracesArgs = Infer< + typeof listCapabilityTracesArgsValidator +>; +export type DeleteExpiredCapabilityTracesArgs = Infer< + typeof deleteExpiredCapabilityTracesArgsValidator +>; diff --git a/packages/backend/convex/chats/utils.test.ts b/packages/backend/convex/chats/utils.test.ts index f74ac19d7e..cde631cdd2 100644 --- a/packages/backend/convex/chats/utils.test.ts +++ b/packages/backend/convex/chats/utils.test.ts @@ -1,4 +1,8 @@ import { chatResponseFailureCode } from "@repo/ai/config/generation"; +import type { + NinaContextSnapshot, + NinaContextTransition, +} from "@repo/ai/nina/memory/pack"; import { mapDBMessagesToUIMessages } from "@repo/backend/convex/chats/utils"; import schema from "@repo/backend/convex/schema"; import { convexModules } from "@repo/backend/convex/test.setup"; @@ -6,6 +10,27 @@ import { convexTest } from "convex-test"; import { describe, expect, it } from "vitest"; const now = Date.UTC(2026, 5, 6, 0, 0, 0); +const ninaContextSnapshot = { + capturedAt: "2026-06-06T00:00:00.000Z", + learning: { + locale: "en", + slug: "subjects/mathematics/vector/addition", + url: "https://nakafa.com/en/subjects/mathematics/vector/addition", + verified: true, + }, + source: "current-page", + tools: { + allowDeepResearch: true, + allowMath: true, + allowNakafa: true, + allowPageFetch: true, + evidenceScope: "verified-page", + }, +} satisfies NinaContextSnapshot; +const ninaContextTransition = { + reason: "page-context", + toContextKey: "canonical:subjects/mathematics/vector/addition", +} satisfies NinaContextTransition; describe("mapDBMessagesToUIMessages", () => { it("preserves persisted assistant generation failure metadata", async () => { @@ -34,6 +59,8 @@ describe("mapDBMessagesToUIMessages", () => { modelId: "nakafa-lite", generationStatus: "failed", generationErrorCode: chatResponseFailureCode, + ninaContextSnapshot, + ninaContextTransition, }); const messages = await ctx.db.query("messages").collect(); @@ -49,6 +76,8 @@ describe("mapDBMessagesToUIMessages", () => { model: "nakafa-lite", generationStatus: "failed", generationErrorCode: chatResponseFailureCode, + ninaContextSnapshot, + ninaContextTransition, }), }), ]); diff --git a/packages/backend/convex/chats/utils.ts b/packages/backend/convex/chats/utils.ts index fa8a4ef98c..32485acd1a 100644 --- a/packages/backend/convex/chats/utils.ts +++ b/packages/backend/convex/chats/utils.ts @@ -1,7 +1,42 @@ import { defaultModel, ModelIdSchema } from "@repo/ai/config/model"; +import { + NinaContextSnapshotSchema, + NinaContextTransitionSchema, +} from "@repo/ai/nina/memory/pack"; import type { MyUIMessage } from "@repo/ai/types/message"; import type { Doc } from "@repo/backend/convex/_generated/dataModel"; import { mapDBPartToUIMessagePart } from "@repo/backend/convex/chats/messageParts/dbToUi"; +import { Option, Schema } from "effect"; + +/** Decodes stored Nina snapshots from Convex JSON into branded AI metadata. */ +function readStoredNinaSnapshot( + snapshot: Doc<"messages">["ninaContextSnapshot"] +) { + const decoded = Schema.decodeUnknownOption(NinaContextSnapshotSchema)( + snapshot + ); + + if (Option.isNone(decoded)) { + return; + } + + return decoded.value; +} + +/** Decodes stored Nina transition markers into schema-owned AI metadata. */ +function readStoredNinaTransition( + transition: Doc<"messages">["ninaContextTransition"] +) { + const decoded = Schema.decodeUnknownOption(NinaContextTransitionSchema)( + transition + ); + + if (Option.isNone(decoded)) { + return; + } + + return decoded.value; +} /** * Maps raw DB messages (with parts) to UI messages. @@ -21,6 +56,10 @@ export function mapDBMessagesToUIMessages( credits: message.credits, generationErrorCode: message.generationErrorCode, generationStatus: message.generationStatus, + ninaContextSnapshot: readStoredNinaSnapshot(message.ninaContextSnapshot), + ninaContextTransition: readStoredNinaTransition( + message.ninaContextTransition + ), tokens: message.inputTokens != null || message.outputTokens != null || diff --git a/packages/backend/convex/contentSync/mutations/readModels.ts b/packages/backend/convex/contentSync/mutations/readModels.ts index 0826deba48..46559df1e4 100644 --- a/packages/backend/convex/contentSync/mutations/readModels.ts +++ b/packages/backend/convex/contentSync/mutations/readModels.ts @@ -424,7 +424,10 @@ function isSamePublicRoute( existing.displayGroupTitle === next.displayGroupTitle && existing.iconKey === next.iconKey && existing.kind === next.kind && + existing.level === next.level && existing.locale === next.locale && + existing.materialCardDescription === next.materialCardDescription && + existing.materialCardTitle === next.materialCardTitle && existing.materialDomain === next.materialDomain && existing.materialKey === next.materialKey && existing.nodeKey === next.nodeKey && diff --git a/packages/backend/convex/contentSync/mutations/readModels/schema.ts b/packages/backend/convex/contentSync/mutations/readModels/schema.ts index 6e2ae4394f..76844add49 100644 --- a/packages/backend/convex/contentSync/mutations/readModels/schema.ts +++ b/packages/backend/convex/contentSync/mutations/readModels/schema.ts @@ -143,6 +143,7 @@ export const assessmentNodeRowValidator = v.object({ }), }); +/** Canonical public route row written by content sync read-model imports. */ export const publicRouteRowValidator = v.object({ canonicalPath: v.optional(v.string()), description: v.optional(v.string()), @@ -150,7 +151,10 @@ export const publicRouteRowValidator = v.object({ displayGroupTitle: v.optional(v.string()), iconKey: v.optional(navigationIconKeyValidator), kind: publicRouteKindValidator, + level: v.optional(navigationLevelValidator), locale: localeValidator, + materialCardDescription: v.optional(v.string()), + materialCardTitle: v.optional(v.string()), materialDomain: v.optional(materialValidator), materialKey: v.optional(v.string()), nodeKey: v.optional(v.string()), diff --git a/packages/backend/convex/contentSync/queries/integrity.test.ts b/packages/backend/convex/contentSync/queries/integrity.test.ts index a3b975e278..357106ae59 100644 --- a/packages/backend/convex/contentSync/queries/integrity.test.ts +++ b/packages/backend/convex/contentSync/queries/integrity.test.ts @@ -14,10 +14,12 @@ import { describe, expect, it } from "vitest"; const NOW = Date.parse("2026-01-02T00:00:00.000Z"); const ARTICLE_ROUTE = "articles/politics/integrity-article"; const GRAPH_ANALYTICS_INTEGRITY_TARGETS = [ - "contentViews", - "contentViewAnalyticsQueue", - "learningPopularity", - "learningTrendingBuckets", + "learningViews", + "learningEngagementQueue", + "userLearningRecents", + "learningPopularityViewerSignals", + "learningPopularitySignals", + "learningPopularityCounters", ] as const; const GRAPH_AUDIO_INTEGRITY_TARGETS = [ "audioContentSources", @@ -32,6 +34,10 @@ const GRAPH_INTEGRITY_TARGETS = [ ...GRAPH_ANALYTICS_INTEGRITY_TARGETS, ...GRAPH_AUDIO_INTEGRITY_TARGETS, ] as const; +const canonicalContext = { + contextKey: "canonical", + contextMode: "canonical", +} as const; describe("contentSync/queries/integrity", () => { it("reports graph-shaped contentRoute content_id values that differ from assetId", async () => { @@ -223,38 +229,7 @@ describe("contentSync/queries/integrity", () => { const graph = articleGraphWithContentId(articleGraph().assetId); await t.mutation(async (ctx) => { - await ctx.db.insert("contentViews", { - ...graph, - deviceId: "integrity-device", - firstViewedAt: NOW, - lastViewedAt: NOW, - locale: "id", - route: ARTICLE_ROUTE, - section: "articles", - }); - await ctx.db.insert("contentViewAnalyticsQueue", { - ...graph, - locale: "id", - partition: 0, - route: ARTICLE_ROUTE, - section: "articles", - viewedAt: NOW, - }); - await ctx.db.insert("learningPopularity", { - ...graph, - locale: "id", - section: "articles", - updatedAt: NOW, - viewCount: 1, - }); - await ctx.db.insert("learningTrendingBuckets", { - ...graph, - bucketStart: NOW, - locale: "id", - section: "articles", - updatedAt: NOW, - viewCount: 1, - }); + await insertAnalyticsRows(ctx, graph); }); for (const target of GRAPH_ANALYTICS_INTEGRITY_TARGETS) { @@ -267,56 +242,20 @@ describe("contentSync/queries/integrity", () => { const graph = articleGraphWithContentId(`${articleGraph().assetId}:stale`); await t.mutation(async (ctx) => { - await ctx.db.insert("contentViews", { - ...graph, - deviceId: "integrity-device", - firstViewedAt: NOW, - lastViewedAt: NOW, - locale: "id", - route: ARTICLE_ROUTE, - section: "articles", - }); - await ctx.db.insert("contentViewAnalyticsQueue", { - ...graph, - locale: "id", - partition: 0, - route: ARTICLE_ROUTE, - section: "articles", - viewedAt: NOW, - }); - await ctx.db.insert("learningPopularity", { - ...graph, - locale: "id", - section: "articles", - updatedAt: NOW, - viewCount: 1, - }); + await insertAnalyticsRows(ctx, graph); }); - await expectMismatchedGraphIdentityIntegrity(t, "contentViews", { - assetId: graph.assetId, - content_id: graph.content_id, - kind: "contentViews", - route: ARTICLE_ROUTE, - section: "articles", - }); - await expectMismatchedGraphIdentityIntegrity( - t, - "contentViewAnalyticsQueue", - { + for (const target of GRAPH_ANALYTICS_INTEGRITY_TARGETS) { + await expectMismatchedGraphIdentityIntegrity(t, target, { assetId: graph.assetId, content_id: graph.content_id, - kind: "contentViewAnalyticsQueue", - route: ARTICLE_ROUTE, + kind: target, + ...(target === "learningPopularityViewerSignals" + ? {} + : { route: ARTICLE_ROUTE }), section: "articles", - } - ); - await expectMismatchedGraphIdentityIntegrity(t, "learningPopularity", { - assetId: graph.assetId, - content_id: graph.content_id, - kind: "learningPopularity", - section: "articles", - }); + }); + } }); it("accepts audio rows that store graph identity", async () => { @@ -410,6 +349,97 @@ async function expectMismatchedGraphIdentityIntegrity( }); } +/** Inserts one row in each final graph-backed engagement table. */ +async function insertAnalyticsRows( + ctx: MutationCtx, + graph: ReturnType +) { + const userId = await ctx.db.insert("users", { + authId: "analytics-integrity-user", + credits: 0, + creditsResetAt: NOW, + email: "analytics-integrity@example.com", + name: "Analytics Integrity", + plan: "free", + }); + + await ctx.db.insert("learningViews", { + ...graph, + ...canonicalContext, + deviceId: "integrity-device", + firstViewedAt: NOW, + lastViewedAt: NOW, + locale: "id", + route: ARTICLE_ROUTE, + section: "articles", + }); + await ctx.db.insert("learningEngagementQueue", { + ...graph, + ...canonicalContext, + description: "Integrity article", + insertedAt: NOW, + locale: "id", + partition: 0, + route: ARTICLE_ROUTE, + scopeMode: "global", + section: "articles", + sourcePath: ARTICLE_ROUTE, + title: "Integrity Article", + viewedAt: NOW, + viewerKey: "device:integrity-device", + }); + await ctx.db.insert("userLearningRecents", { + ...graph, + ...canonicalContext, + description: "Integrity article", + lastViewedAt: NOW, + locale: "id", + route: ARTICLE_ROUTE, + section: "articles", + sourcePath: ARTICLE_ROUTE, + title: "Integrity Article", + userId, + }); + await ctx.db.insert("learningPopularityViewerSignals", { + ...graph, + ...canonicalContext, + locale: "id", + scopeMode: "global", + section: "articles", + signalDay: NOW, + viewedAt: NOW, + viewerKey: "device:integrity-device", + }); + await ctx.db.insert("learningPopularitySignals", { + ...graph, + ...canonicalContext, + description: "Integrity article", + locale: "id", + route: ARTICLE_ROUTE, + scopeMode: "global", + section: "articles", + signalDay: NOW, + sourcePath: ARTICLE_ROUTE, + title: "Integrity Article", + updatedAt: NOW, + viewCount: 1, + }); + await ctx.db.insert("learningPopularityCounters", { + ...graph, + ...canonicalContext, + description: "Integrity article", + locale: "id", + route: ARTICLE_ROUTE, + score: 1, + scopeMode: "global", + section: "articles", + sourcePath: ARTICLE_ROUTE, + title: "Integrity Article", + updatedAt: NOW, + windowKey: "7d", + }); +} + /** Inserts one row in each graph-backed audio table. */ async function insertAudioRows( ctx: MutationCtx, diff --git a/packages/backend/convex/contentSync/queries/integrity.ts b/packages/backend/convex/contentSync/queries/integrity.ts index 5fa1013cf8..7cf5827c10 100644 --- a/packages/backend/convex/contentSync/queries/integrity.ts +++ b/packages/backend/convex/contentSync/queries/integrity.ts @@ -53,10 +53,12 @@ const graphIdentityTargets = [ "contentSearch", "contentRoutePages", "parts", - "contentViews", - "contentViewAnalyticsQueue", - "learningPopularity", - "learningTrendingBuckets", + "learningViews", + "learningEngagementQueue", + "userLearningRecents", + "learningPopularityViewerSignals", + "learningPopularitySignals", + "learningPopularityCounters", "audioContentSources", "audioGenerationQueue", "contentAudios", @@ -171,7 +173,7 @@ function isFilled(value: string | undefined) { return typeof value === "string" && value.length > 0; } -/** Detects stale route-shaped content IDs that violate graph identity storage. */ +/** Detects invalid route-shaped content IDs that violate graph identity storage. */ function hasRouteShapedContentId(ref: GraphIdentityRef) { return typeof ref.content_id === "string" && ref.content_id.includes("/"); } @@ -312,6 +314,7 @@ function getGraphIdentityPageResult( }; } +/** Returns one bounded exercise-question page for sync integrity verification. */ export const listIntegrityExerciseQuestionsPage = internalQuery({ args: { paginationOpts: paginationOptsValidator, @@ -333,6 +336,7 @@ export const listIntegrityExerciseQuestionsPage = internalQuery({ }, }); +/** Returns one bounded exercise-choice page for sync integrity verification. */ export const listIntegrityExerciseChoicesPage = internalQuery({ args: { paginationOpts: paginationOptsValidator, @@ -352,6 +356,7 @@ export const listIntegrityExerciseChoicesPage = internalQuery({ }, }); +/** Returns one bounded content-author page for sync integrity verification. */ export const listIntegrityContentAuthorsPage = internalQuery({ args: { paginationOpts: paginationOptsValidator, @@ -373,6 +378,7 @@ export const listIntegrityContentAuthorsPage = internalQuery({ }, }); +/** Returns one bounded article-reference page for sync integrity verification. */ export const listIntegrityArticleReferencesPage = internalQuery({ args: { paginationOpts: paginationOptsValidator, @@ -392,6 +398,7 @@ export const listIntegrityArticleReferencesPage = internalQuery({ }, }); +/** Returns one bounded article page for stale-content integrity verification. */ export const listIntegrityArticlesPage = internalQuery({ args: { paginationOpts: paginationOptsValidator, @@ -413,6 +420,7 @@ export const listIntegrityArticlesPage = internalQuery({ }, }); +/** Returns one bounded curriculum-lesson page for sync integrity verification. */ export const listIntegrityCurriculumLessonsPage = internalQuery({ args: { paginationOpts: paginationOptsValidator, @@ -479,9 +487,9 @@ export const getGraphIdentityIntegrityPage = internalQuery({ return getGraphIdentityPageResult(summary, page); } - if (args.target === "contentViews") { + if (args.target === "learningViews") { const page = await ctx.db - .query("contentViews") + .query("learningViews") .paginate(args.paginationOpts); for (const row of page.page) { @@ -491,9 +499,9 @@ export const getGraphIdentityIntegrityPage = internalQuery({ return getGraphIdentityPageResult(summary, page); } - if (args.target === "contentViewAnalyticsQueue") { + if (args.target === "learningEngagementQueue") { const page = await ctx.db - .query("contentViewAnalyticsQueue") + .query("learningEngagementQueue") .paginate(args.paginationOpts); for (const row of page.page) { @@ -503,9 +511,9 @@ export const getGraphIdentityIntegrityPage = internalQuery({ return getGraphIdentityPageResult(summary, page); } - if (args.target === "learningPopularity") { + if (args.target === "userLearningRecents") { const page = await ctx.db - .query("learningPopularity") + .query("userLearningRecents") .paginate(args.paginationOpts); for (const row of page.page) { @@ -515,17 +523,37 @@ export const getGraphIdentityIntegrityPage = internalQuery({ return getGraphIdentityPageResult(summary, page); } - if (args.target === "learningTrendingBuckets") { + if (args.target === "learningPopularitySignals") { const page = await ctx.db - .query("learningTrendingBuckets") + .query("learningPopularitySignals") .paginate(args.paginationOpts); for (const row of page.page) { - checkGraphIdentityRef( - summary, - { ...row, section: "material" }, - args.target - ); + checkGraphIdentityRef(summary, row, args.target); + } + + return getGraphIdentityPageResult(summary, page); + } + + if (args.target === "learningPopularityViewerSignals") { + const page = await ctx.db + .query("learningPopularityViewerSignals") + .paginate(args.paginationOpts); + + for (const row of page.page) { + checkGraphIdentityRef(summary, row, args.target); + } + + return getGraphIdentityPageResult(summary, page); + } + + if (args.target === "learningPopularityCounters") { + const page = await ctx.db + .query("learningPopularityCounters") + .paginate(args.paginationOpts); + + for (const row of page.page) { + checkGraphIdentityRef(summary, row, args.target); } return getGraphIdentityPageResult(summary, page); diff --git a/packages/backend/convex/contentSync/reset/impl.test.ts b/packages/backend/convex/contentSync/reset/impl.test.ts index f8301d510e..bb0ff6a679 100644 --- a/packages/backend/convex/contentSync/reset/impl.test.ts +++ b/packages/backend/convex/contentSync/reset/impl.test.ts @@ -9,6 +9,11 @@ import { convexTest } from "convex-test"; import { describe, expect, it } from "vitest"; import { deleteBatchFromTable } from "./impl"; +const canonicalContext = { + contextKey: "canonical", + contextMode: "canonical", +} as const; + describe("contentSync/reset/impl", () => { it("deletes runtime and analytics rows through bounded reset batches", async () => { const t = convexTest(schema, convexModules); @@ -17,15 +22,21 @@ describe("contentSync/reset/impl", () => { const routeDelete = await t.mutation(deleteContentRoutesBatch); const publicRouteDelete = await t.mutation(deletePublicRoutesBatch); - const viewDelete = await t.mutation(deleteContentViewsBatch); - const queueDelete = await t.mutation(deleteContentViewAnalyticsQueueBatch); + const viewDelete = await t.mutation(deleteLearningViewsBatch); + const queueDelete = await t.mutation(deleteLearningEngagementQueueBatch); const partitionDelete = await t.mutation( deleteContentAnalyticsPartitionsBatch ); - const learningPopularityDelete = await t.mutation( - deleteLearningPopularityBatch + const recentsDelete = await t.mutation(deleteUserLearningRecentsBatch); + const viewerSignalsDelete = await t.mutation( + deleteLearningPopularityViewerSignalsBatch + ); + const popularitySignalsDelete = await t.mutation( + deleteLearningPopularitySignalsBatch + ); + const popularityCountersDelete = await t.mutation( + deleteLearningPopularityCountersBatch ); - const trendingDelete = await t.mutation(deleteLearningTrendingBucketsBatch); const materialLocaleDelete = await t.mutation(deleteMaterialLocalesBatch); const materialDelete = await t.mutation(deleteMaterialsBatch); const curriculumMaterialDelete = await t.mutation( @@ -46,8 +57,10 @@ describe("contentSync/reset/impl", () => { expect(viewDelete).toEqual({ deleted: 1, hasMore: false }); expect(queueDelete).toEqual({ deleted: 1, hasMore: false }); expect(partitionDelete).toEqual({ deleted: 1, hasMore: false }); - expect(learningPopularityDelete).toEqual({ deleted: 1, hasMore: false }); - expect(trendingDelete).toEqual({ deleted: 1, hasMore: false }); + expect(recentsDelete).toEqual({ deleted: 1, hasMore: false }); + expect(viewerSignalsDelete).toEqual({ deleted: 1, hasMore: false }); + expect(popularitySignalsDelete).toEqual({ deleted: 1, hasMore: false }); + expect(popularityCountersDelete).toEqual({ deleted: 1, hasMore: false }); expect(materialLocaleDelete).toEqual({ deleted: 1, hasMore: false }); expect(materialDelete).toEqual({ deleted: 1, hasMore: false }); expect(curriculumMaterialDelete).toEqual({ deleted: 1, hasMore: false }); @@ -65,10 +78,12 @@ describe("contentSync/reset/impl", () => { expect.objectContaining({ key: "fixture.program" }), ]); expect(resetRows).toEqual({ - learningPopularity: [], contentAnalyticsPartitions: [], - contentViewAnalyticsQueue: [], - contentViews: [], + learningEngagementQueue: [], + learningPopularityCounters: [], + learningPopularitySignals: [], + learningPopularityViewerSignals: [], + learningViews: [], learningPlanItems: [], assessmentNodes: [], assessments: [], @@ -79,8 +94,8 @@ describe("contentSync/reset/impl", () => { materials: [], publicRoutes: [], routes: [], - learningTrendingBuckets: [], surahs: [], + userLearningRecents: [], verses: [], }); }); @@ -113,8 +128,18 @@ async function seedDerivedRuntimeRows(ctx: MutationCtx) { syncedAt: 1, title: "Fixture Topic", }); - await ctx.db.insert("contentViews", { + const recentUserId = await ctx.db.insert("users", { + authId: "reset-user", + credits: 0, + creditsResetAt: 1, + email: "reset@example.com", + name: "Reset User", + plan: "free", + }); + + await ctx.db.insert("learningViews", { ...graph, + ...canonicalContext, deviceId: "device-1", firstViewedAt: 1, lastViewedAt: 2, @@ -122,12 +147,19 @@ async function seedDerivedRuntimeRows(ctx: MutationCtx) { route: "quran/1", section: "quran", }); - await ctx.db.insert("contentViewAnalyticsQueue", { + await ctx.db.insert("learningEngagementQueue", { ...graph, + ...canonicalContext, + description: "Quran reset", + insertedAt: 2, locale: "id", partition: 0, route: "quran/1", + scopeMode: "global", section: "quran", + sourcePath: "quran/1", + title: "Al-Fatihah", + viewerKey: "device:device-1", viewedAt: 2, }); await ctx.db.insert("contentAnalyticsPartitions", { @@ -135,20 +167,55 @@ async function seedDerivedRuntimeRows(ctx: MutationCtx) { leaseVersion: 1, partition: 0, }); - await ctx.db.insert("learningPopularity", { + await ctx.db.insert("userLearningRecents", { + ...graph, + ...canonicalContext, + description: "Quran reset", + lastViewedAt: 2, + locale: "id", + route: "quran/1", + section: "quran", + sourcePath: "quran/1", + title: "Al-Fatihah", + userId: recentUserId, + }); + await ctx.db.insert("learningPopularityViewerSignals", { ...graph, + ...canonicalContext, locale: "id", + scopeMode: "global", section: "quran", + signalDay: 0, + viewedAt: 2, + viewerKey: "device:device-1", + }); + await ctx.db.insert("learningPopularitySignals", { + ...graph, + ...canonicalContext, + description: "Quran reset", + locale: "id", + route: "quran/1", + scopeMode: "global", + section: "quran", + signalDay: 0, + sourcePath: "quran/1", + title: "Al-Fatihah", updatedAt: 2, viewCount: 1, }); - await ctx.db.insert("learningTrendingBuckets", { + await ctx.db.insert("learningPopularityCounters", { ...graph, - bucketStart: 0, + ...canonicalContext, + description: "Quran reset", locale: "id", + route: "quran/1", + score: 1, + scopeMode: "global", section: "quran", + sourcePath: "quran/1", + title: "Al-Fatihah", updatedAt: 2, - viewCount: 1, + windowKey: "7d", }); await ctx.db.insert("materials", { concepts: [], @@ -392,14 +459,14 @@ async function deletePublicRoutesBatch(ctx: MutationCtx) { return await deleteBatchFromTable(ctx, "publicRoutes"); } -/** Deletes one content view reset batch through the shared reset helper. */ -async function deleteContentViewsBatch(ctx: MutationCtx) { - return await deleteBatchFromTable(ctx, "contentViews"); +/** Deletes one learning view reset batch through the shared reset helper. */ +async function deleteLearningViewsBatch(ctx: MutationCtx) { + return await deleteBatchFromTable(ctx, "learningViews"); } -/** Deletes one content view analytics queue reset batch. */ -async function deleteContentViewAnalyticsQueueBatch(ctx: MutationCtx) { - return await deleteBatchFromTable(ctx, "contentViewAnalyticsQueue"); +/** Deletes one learning engagement queue reset batch. */ +async function deleteLearningEngagementQueueBatch(ctx: MutationCtx) { + return await deleteBatchFromTable(ctx, "learningEngagementQueue"); } /** Deletes one content analytics partition reset batch. */ @@ -407,14 +474,24 @@ async function deleteContentAnalyticsPartitionsBatch(ctx: MutationCtx) { return await deleteBatchFromTable(ctx, "contentAnalyticsPartitions"); } -/** Deletes one learning popularity reset batch. */ -async function deleteLearningPopularityBatch(ctx: MutationCtx) { - return await deleteBatchFromTable(ctx, "learningPopularity"); +/** Deletes one signed-in Continue Learning reset batch. */ +async function deleteUserLearningRecentsBatch(ctx: MutationCtx) { + return await deleteBatchFromTable(ctx, "userLearningRecents"); +} + +/** Deletes one daily viewer popularity signal reset batch. */ +async function deleteLearningPopularityViewerSignalsBatch(ctx: MutationCtx) { + return await deleteBatchFromTable(ctx, "learningPopularityViewerSignals"); +} + +/** Deletes one aggregated daily popularity signal reset batch. */ +async function deleteLearningPopularitySignalsBatch(ctx: MutationCtx) { + return await deleteBatchFromTable(ctx, "learningPopularitySignals"); } -/** Deletes one learning trending bucket reset batch. */ -async function deleteLearningTrendingBucketsBatch(ctx: MutationCtx) { - return await deleteBatchFromTable(ctx, "learningTrendingBuckets"); +/** Deletes one ranked popularity counter reset batch. */ +async function deleteLearningPopularityCountersBatch(ctx: MutationCtx) { + return await deleteBatchFromTable(ctx, "learningPopularityCounters"); } /** Deletes one material locale reset batch. */ @@ -470,14 +547,22 @@ async function deleteQuranSurahsBatch(ctx: MutationCtx) { /** Reads the derived runtime tables after reset has run. */ async function getDerivedRuntimeRows(ctx: QueryCtx) { return { - learningPopularity: await ctx.db.query("learningPopularity").collect(), contentAnalyticsPartitions: await ctx.db .query("contentAnalyticsPartitions") .collect(), - contentViewAnalyticsQueue: await ctx.db - .query("contentViewAnalyticsQueue") + learningEngagementQueue: await ctx.db + .query("learningEngagementQueue") .collect(), - contentViews: await ctx.db.query("contentViews").collect(), + learningPopularityCounters: await ctx.db + .query("learningPopularityCounters") + .collect(), + learningPopularitySignals: await ctx.db + .query("learningPopularitySignals") + .collect(), + learningPopularityViewerSignals: await ctx.db + .query("learningPopularityViewerSignals") + .collect(), + learningViews: await ctx.db.query("learningViews").collect(), learningPlanItems: await ctx.db.query("learningPlanItems").collect(), assessmentNodes: await ctx.db.query("assessmentNodes").collect(), assessments: await ctx.db.query("assessments").collect(), @@ -492,10 +577,8 @@ async function getDerivedRuntimeRows(ctx: QueryCtx) { materials: await ctx.db.query("materials").collect(), publicRoutes: await ctx.db.query("publicRoutes").collect(), routes: await ctx.db.query("contentRoutes").collect(), - learningTrendingBuckets: await ctx.db - .query("learningTrendingBuckets") - .collect(), surahs: await ctx.db.query("quranSurahs").collect(), + userLearningRecents: await ctx.db.query("userLearningRecents").collect(), verses: await ctx.db.query("quranVerses").collect(), }; } diff --git a/packages/backend/convex/contentSync/reset/internal.ts b/packages/backend/convex/contentSync/reset/internal.ts index 9eb2c24823..1a7dd3fe25 100644 --- a/packages/backend/convex/contentSync/reset/internal.ts +++ b/packages/backend/convex/contentSync/reset/internal.ts @@ -29,22 +29,35 @@ export const deleteContentSearchBatch = internalMutation({ returns: batchDeleteResultValidator, handler: deleteContentSearchRows, }); -export const deleteContentViewsBatch = - createBatchDeleteMutation("contentViews"); -export const deleteContentViewAnalyticsQueueBatch = createBatchDeleteMutation( - "contentViewAnalyticsQueue" -); +/** Delete one bounded batch of stored learning view rows. */ +export const deleteLearningViewsBatch = + createBatchDeleteMutation("learningViews"); +/** Delete one bounded batch of queued learning engagement events. */ +export const deleteLearningEngagementQueueBatch = createBatchDeleteMutation( + "learningEngagementQueue" +); +/** Delete one bounded batch of analytics partition checkpoint rows. */ export const deleteContentAnalyticsPartitionsBatch = createBatchDeleteMutation( "contentAnalyticsPartitions" ); -/** Delete one bounded batch of graph-backed learning popularity rows. */ -export const deleteLearningPopularityBatch = - createBatchDeleteMutation("learningPopularity"); +/** Delete one bounded batch of graph-backed user learning recents rows. */ +export const deleteUserLearningRecentsBatch = createBatchDeleteMutation( + "userLearningRecents" +); + +/** Delete one bounded batch of graph-backed learning popularity signal rows. */ +export const deleteLearningPopularitySignalsBatch = createBatchDeleteMutation( + "learningPopularitySignals" +); + +/** Delete one bounded batch of daily viewer popularity dedupe rows. */ +export const deleteLearningPopularityViewerSignalsBatch = + createBatchDeleteMutation("learningPopularityViewerSignals"); -/** Delete one bounded batch of graph-backed learning trend bucket rows. */ -export const deleteLearningTrendingBucketsBatch = createBatchDeleteMutation( - "learningTrendingBuckets" +/** Delete one bounded batch of graph-backed learning popularity counter rows. */ +export const deleteLearningPopularityCountersBatch = createBatchDeleteMutation( + "learningPopularityCounters" ); /** Delete one bounded batch of generated material identity rows. */ diff --git a/packages/backend/convex/contentSync/reset/spec.ts b/packages/backend/convex/contentSync/reset/spec.ts index be6ab5809b..4432c08c95 100644 --- a/packages/backend/convex/contentSync/reset/spec.ts +++ b/packages/backend/convex/contentSync/reset/spec.ts @@ -23,8 +23,8 @@ export const resettableTableNames = [ "contentRoutes", "publicRoutes", "contentSearch", - "contentViewAnalyticsQueue", - "contentViews", + "learningEngagementQueue", + "learningViews", "exerciseAnswers", "exerciseAttempts", "exerciseChoices", @@ -40,7 +40,9 @@ export const resettableTableNames = [ "irtScaleQualityRefreshQueue", "irtScaleVersionItems", "irtScaleVersions", - "learningPopularity", + "learningPopularityCounters", + "learningPopularitySignals", + "learningPopularityViewerSignals", "assessmentNodes", "assessments", "curricula", @@ -54,7 +56,7 @@ export const resettableTableNames = [ "quranVerses", "curriculumLessons", "curriculumTopics", - "learningTrendingBuckets", + "userLearningRecents", "tryoutAccessCampaignProducts", "tryoutAccessCampaigns", "tryoutAccessGrants", diff --git a/packages/backend/convex/contentSync/tables.ts b/packages/backend/convex/contentSync/tables.ts index 86f969089d..110bac9c96 100644 --- a/packages/backend/convex/contentSync/tables.ts +++ b/packages/backend/convex/contentSync/tables.ts @@ -45,17 +45,28 @@ export const contentCountTables = [ { field: "irtScaleVersions", tableName: "irtScaleVersions" }, { field: "irtScaleVersionItems", tableName: "irtScaleVersionItems" }, { field: "contentSearch", tableName: "contentSearch" }, - { field: "contentViews", tableName: "contentViews" }, + { field: "learningViews", tableName: "learningViews" }, { - field: "contentViewAnalyticsQueue", - tableName: "contentViewAnalyticsQueue", + field: "learningEngagementQueue", + tableName: "learningEngagementQueue", }, { field: "contentAnalyticsPartitions", tableName: "contentAnalyticsPartitions", }, - { field: "learningPopularity", tableName: "learningPopularity" }, - { field: "learningTrendingBuckets", tableName: "learningTrendingBuckets" }, + { field: "userLearningRecents", tableName: "userLearningRecents" }, + { + field: "learningPopularityViewerSignals", + tableName: "learningPopularityViewerSignals", + }, + { + field: "learningPopularitySignals", + tableName: "learningPopularitySignals", + }, + { + field: "learningPopularityCounters", + tableName: "learningPopularityCounters", + }, { field: "materials", tableName: "materials" }, { field: "materialLocales", tableName: "materialLocales" }, { field: "curricula", tableName: "curricula" }, diff --git a/packages/backend/convex/contents/actions/queue.test.ts b/packages/backend/convex/contents/actions/queue.test.ts index e4879e5996..0aa51c7eaa 100644 --- a/packages/backend/convex/contents/actions/queue.test.ts +++ b/packages/backend/convex/contents/actions/queue.test.ts @@ -1,11 +1,15 @@ import { internal } from "@repo/backend/convex/_generated/api"; -import type { ActionCtx } from "@repo/backend/convex/_generated/server"; +import type { + ActionCtx, + MutationCtx, +} from "@repo/backend/convex/_generated/server"; import { MIN_VIEW_THRESHOLD } from "@repo/backend/convex/audioStudies/constants"; import { chunkPopularAudioItems, populateAudioGenerationQueue, } from "@repo/backend/convex/contents/audioQueue/impl"; import { audioQueuePopulationFailedCode } from "@repo/backend/convex/contents/audioQueue/spec"; +import { getLifetimePopularityWindow } from "@repo/backend/convex/contents/popularity"; import type { PopularAudioContentItem } from "@repo/backend/convex/contents/validators"; import schema from "@repo/backend/convex/schema"; import { getTestAudioContent } from "@repo/backend/convex/test.helpers"; @@ -23,6 +27,31 @@ const articleSource = getTestAudioContent({ locale: "en", route: ARTICLE_ROUTE, }); +const canonicalContext = { + contextKey: "canonical", + contextMode: "canonical", +} as const; + +/** Inserts the lifetime global popularity counter consumed by audio queue reads. */ +async function insertArticlePopularityCounter( + ctx: MutationCtx, + graph: NonNullable> +) { + await ctx.db.insert("learningPopularityCounters", { + ...graph, + ...canonicalContext, + content_id: graph.assetId, + locale: "en", + route: ARTICLE_ROUTE, + score: MIN_VIEW_THRESHOLD, + section: "articles", + scopeMode: "global", + sourcePath: ARTICLE_ROUTE, + title: "Dynastic Politics", + updatedAt: NOW, + windowKey: getLifetimePopularityWindow(), + }); +} describe("contents/actions/queue", () => { beforeEach(() => { @@ -169,14 +198,7 @@ describe("contents/actions/queue", () => { title: "Dynastic Politics", }); - await ctx.db.insert("learningPopularity", { - ...graph, - content_id: graph.assetId, - locale: "en", - section: "articles", - updatedAt: NOW, - viewCount: MIN_VIEW_THRESHOLD, - }); + await insertArticlePopularityCounter(ctx, graph); await ctx.db.insert("audioContentSources", { ...articleSource, syncedAt: NOW, diff --git a/packages/backend/convex/contents/analytics/impl.ts b/packages/backend/convex/contents/analytics/impl.ts index ba134a7ea1..9a579858cd 100644 --- a/packages/backend/convex/contents/analytics/impl.ts +++ b/packages/backend/convex/contents/analytics/impl.ts @@ -14,38 +14,44 @@ import { CONTENT_ANALYTICS_PARTITIONS, } from "@repo/backend/convex/contents/constants"; import { isContentAnalyticsPartition } from "@repo/backend/convex/contents/helpers/partitions"; -import { applyContentAnalyticsBatch } from "@repo/backend/convex/contents/helpers/writes"; +import { applyContentAnalyticsBatch } from "@repo/backend/convex/contents/metrics/apply"; import { logger } from "@repo/backend/convex/utils/logger"; import type { FunctionReference } from "convex/server"; import { Clock, Effect } from "effect"; -export interface ContentAnalyticsSchedulerTargets { - readonly processPartition: FunctionReference< - "mutation", - "internal", - ProcessContentAnalyticsPartitionArgs, - ProcessContentAnalyticsPartitionResult - >; - readonly schedulePartition: FunctionReference< - "mutation", - "internal", - ScheduleContentAnalyticsPartitionArgs, - ScheduleContentAnalyticsPartitionResult - >; -} +/** Generated internal mutation reference that claims analytics partitions. */ +type ScheduleContentAnalyticsPartitionReference = FunctionReference< + "mutation", + "internal", + ScheduleContentAnalyticsPartitionArgs, + ScheduleContentAnalyticsPartitionResult +>; + +/** Generated internal mutation reference that drains a claimed partition. */ +type ProcessContentAnalyticsPartitionReference = FunctionReference< + "mutation", + "internal", + ProcessContentAnalyticsPartitionArgs, + ProcessContentAnalyticsPartitionResult +>; /** Schedules worker attempts only for partitions that currently have queued views. */ export const scheduleAllContentAnalyticsPartitions = Effect.fn( "contents.analytics.scheduleAllContentAnalyticsPartitions" -)(function* (ctx: MutationCtx, targets: ContentAnalyticsSchedulerTargets) { +)(function* ( + ctx: MutationCtx, + schedulePartition: ScheduleContentAnalyticsPartitionReference +) { let enqueuedPartitions = 0; for (const partition of CONTENT_ANALYTICS_PARTITIONS) { const queuedItem = yield* Effect.tryPromise({ try: () => ctx.db - .query("contentViewAnalyticsQueue") - .withIndex("by_partition", (q) => q.eq("partition", partition)) + .query("learningEngagementQueue") + .withIndex("by_partition_and_insertedAt", (q) => + q.eq("partition", partition) + ) .first(), catch: toContentAnalyticsIoError, }); @@ -55,10 +61,7 @@ export const scheduleAllContentAnalyticsPartitions = Effect.fn( } yield* Effect.tryPromise({ - try: () => - ctx.scheduler.runAfter(0, targets.schedulePartition, { - partition, - }), + try: () => ctx.scheduler.runAfter(0, schedulePartition, { partition }), catch: toContentAnalyticsIoError, }); @@ -82,7 +85,7 @@ export const claimContentAnalyticsPartition = Effect.fn( )(function* ( ctx: MutationCtx, args: ScheduleContentAnalyticsPartitionArgs, - targets: ContentAnalyticsSchedulerTargets + processPartition: ProcessContentAnalyticsPartitionReference ) { if (!isContentAnalyticsPartition(args.partition)) { return yield* Effect.fail( @@ -96,8 +99,10 @@ export const claimContentAnalyticsPartition = Effect.fn( const queuedItem = yield* Effect.tryPromise({ try: () => ctx.db - .query("contentViewAnalyticsQueue") - .withIndex("by_partition", (q) => q.eq("partition", args.partition)) + .query("learningEngagementQueue") + .withIndex("by_partition_and_insertedAt", (q) => + q.eq("partition", args.partition) + ) .first(), catch: toContentAnalyticsIoError, }); @@ -157,7 +162,7 @@ export const claimContentAnalyticsPartition = Effect.fn( yield* Effect.tryPromise({ try: () => - ctx.scheduler.runAfter(0, targets.processPartition, { + ctx.scheduler.runAfter(0, processPartition, { leaseVersion, partition: args.partition, }), @@ -176,7 +181,7 @@ export const processClaimedContentAnalyticsPartition = Effect.fn( )(function* ( ctx: MutationCtx, args: ProcessContentAnalyticsPartitionArgs, - targets: ContentAnalyticsSchedulerTargets + processPartition: ProcessContentAnalyticsPartitionReference ) { if (!isContentAnalyticsPartition(args.partition)) { return yield* Effect.fail( @@ -227,8 +232,10 @@ export const processClaimedContentAnalyticsPartition = Effect.fn( const queueItems = yield* Effect.tryPromise({ try: () => ctx.db - .query("contentViewAnalyticsQueue") - .withIndex("by_partition", (q) => q.eq("partition", args.partition)) + .query("learningEngagementQueue") + .withIndex("by_partition_and_insertedAt", (q) => + q.eq("partition", args.partition) + ) .take(CONTENT_ANALYTICS_BATCH_SIZE), catch: toContentAnalyticsIoError, }); @@ -258,7 +265,7 @@ export const processClaimedContentAnalyticsPartition = Effect.fn( for (const queueItem of queueItems) { yield* Effect.tryPromise({ - try: () => ctx.db.delete("contentViewAnalyticsQueue", queueItem._id), + try: () => ctx.db.delete("learningEngagementQueue", queueItem._id), catch: toContentAnalyticsIoError, }); } @@ -279,7 +286,7 @@ export const processClaimedContentAnalyticsPartition = Effect.fn( if (hasMore) { yield* Effect.tryPromise({ - try: () => ctx.scheduler.runAfter(0, targets.processPartition, args), + try: () => ctx.scheduler.runAfter(0, processPartition, args), catch: toContentAnalyticsIoError, }); } diff --git a/packages/backend/convex/contents/analytics/spec.ts b/packages/backend/convex/contents/analytics/spec.ts index d07bf12f0d..0c210b53b8 100644 --- a/packages/backend/convex/contents/analytics/spec.ts +++ b/packages/backend/convex/contents/analytics/spec.ts @@ -1,11 +1,23 @@ +import { + learningPopularityScopeValues, + learningPopularityWindowValues, +} from "@repo/backend/convex/contents/popularity"; import { getUnknownErrorMessage } from "@repo/backend/convex/lib/effect"; import { type Infer, v } from "convex/values"; +import { literals } from "convex-helpers/validators"; import { Schema } from "effect"; export const invalidContentAnalyticsPartitionCode = "INVALID_CONTENT_ANALYTICS_PARTITION"; export const contentAnalyticsIoFailedCode = "CONTENT_ANALYTICS_IO_FAILED"; +const learningPopularityWindowValidator = literals( + ...learningPopularityWindowValues +); +const learningPopularityScopeValidator = literals( + ...learningPopularityScopeValues +); + export const scheduleContentAnalyticsPartitionsResultValidator = v.object({ enqueuedPartitions: v.number(), }); @@ -39,6 +51,31 @@ export const processContentAnalyticsPartitionResultValidator = v.object({ skipped: v.boolean(), }); +/** Scheduler result returned after enqueueing popularity window refresh work. */ +export const scheduleLearningPopularityRefreshesResultValidator = v.object({ + scheduledWindows: v.number(), +}); + +export const refreshLearningPopularityWindowPageArgs = { + cursor: v.optional(v.string()), + scopeMode: learningPopularityScopeValidator, + windowKey: learningPopularityWindowValidator, +}; + +/** Public validator for one paginated popularity window refresh invocation. */ +export const refreshLearningPopularityWindowPageArgsValidator = v.object( + refreshLearningPopularityWindowPageArgs +); + +/** Progress contract returned by a bounded popularity refresh page. */ +export const refreshLearningPopularityWindowPageResultValidator = v.object({ + continueCursor: v.string(), + isDone: v.boolean(), + refreshedCounters: v.number(), + removedCounters: v.number(), + skipped: v.boolean(), +}); + export type ScheduleContentAnalyticsPartitionArgs = Infer< typeof scheduleContentAnalyticsPartitionArgsValidator >; @@ -59,6 +96,18 @@ export type ProcessContentAnalyticsPartitionResult = Infer< typeof processContentAnalyticsPartitionResultValidator >; +export type ScheduleLearningPopularityRefreshesResult = Infer< + typeof scheduleLearningPopularityRefreshesResultValidator +>; + +export type RefreshLearningPopularityWindowPageArgs = Infer< + typeof refreshLearningPopularityWindowPageArgsValidator +>; + +export type RefreshLearningPopularityWindowPageResult = Infer< + typeof refreshLearningPopularityWindowPageResultValidator +>; + /** Raised when a requested analytics partition is outside the configured set. */ export class InvalidContentAnalyticsPartitionError extends Schema.TaggedError()( "InvalidContentAnalyticsPartitionError", diff --git a/packages/backend/convex/contents/constants.ts b/packages/backend/convex/contents/constants.ts index dac5f20cf8..609c8d2781 100644 --- a/packages/backend/convex/contents/constants.ts +++ b/packages/backend/convex/contents/constants.ts @@ -6,6 +6,9 @@ import { /** Number of queued analytics rows processed in one mutation. */ export const CONTENT_ANALYTICS_BATCH_SIZE = 250; +/** Number of popularity counters recomputed from daily signals in one mutation. */ +export const LEARNING_POPULARITY_REFRESH_BATCH_SIZE = 10; + /** Number of independent analytics partitions. */ export const CONTENT_ANALYTICS_PARTITION_COUNT = 16; diff --git a/packages/backend/convex/contents/context.ts b/packages/backend/convex/contents/context.ts new file mode 100644 index 0000000000..c0bb5c32a7 --- /dev/null +++ b/packages/backend/convex/contents/context.ts @@ -0,0 +1,94 @@ +import { encodeMaterialContextHint } from "@repo/contents/_types/route/material/context"; +import { type Infer, v } from "convex/values"; +import { literals } from "convex-helpers/validators"; + +export const learningContextModeValues = [ + "canonical", + "placement", + "session", + "memory-assisted", +] as const; + +const learningContextModeValidator = literals(...learningContextModeValues); + +export const learningContextInputModeValues = [ + "placement", + "session", + "memory-assisted", +] as const; + +const learningContextInputModeValidator = literals( + ...learningContextInputModeValues +); + +/** + * Optional client-provided learning context hint. + * + * The backend verifies these fields against durable route rows before storing + * them. Missing or invalid hints become canonical context. + */ +export const learningContextInputValidator = v.object({ + mode: learningContextInputModeValidator, + nodeKey: v.optional(v.string()), + programKey: v.optional(v.string()), +}); + +/** + * Persisted learning-context projection shared by views, recents, and counters. + * + * `contextKey` is the indexed grouping key. The other fields preserve only + * verified source identity, never display text or invented curriculum labels. + */ +export const learningContextStorageFields = { + contextKey: v.string(), + contextMaterialKey: v.optional(v.string()), + contextMode: learningContextModeValidator, + contextNodeKey: v.optional(v.string()), + contextParentPath: v.optional(v.string()), + contextProgramKey: v.optional(v.string()), + contextPublicPath: v.optional(v.string()), + contextSourcePath: v.optional(v.string()), +}; + +/** Persisted material/question context fields attached to engagement rows. */ +export const learningContextStorageValidator = v.object( + learningContextStorageFields +); + +export type LearningContextInput = Infer; +export type LearningContextStorage = Infer< + typeof learningContextStorageValidator +>; + +/** Returns the storage projection for a canonical asset visit. */ +export function createCanonicalLearningContext(): LearningContextStorage { + return { + contextKey: "canonical", + contextMode: "canonical", + }; +} + +/** Returns the stable grouping key for a verified placement context. */ +export function createContextKey(input: { + readonly mode: Exclude; + readonly nodeKey: string; + readonly programKey: string; +}) { + return `${input.mode}:${input.programKey}:${input.nodeKey}`; +} + +/** Encodes verified context storage as an optional material URL query string. */ +export function toLearningContextQuery(context: LearningContextStorage) { + if (context.contextMode === "canonical") { + return ""; + } + + if (!(context.contextProgramKey && context.contextNodeKey)) { + return ""; + } + + return `?ctx=${encodeMaterialContextHint({ + nodeKey: context.contextNodeKey, + programKey: context.contextProgramKey, + })}`; +} diff --git a/packages/backend/convex/contents/helpers/writes.test.ts b/packages/backend/convex/contents/helpers/writes.test.ts deleted file mode 100644 index 067a30c3ff..0000000000 --- a/packages/backend/convex/contents/helpers/writes.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import type { Doc } from "@repo/backend/convex/_generated/dataModel"; -import type { MutationCtx } from "@repo/backend/convex/_generated/server"; -import { applyContentAnalyticsBatch } from "@repo/backend/convex/contents/helpers/writes"; -import { getTrendingBucketStart } from "@repo/backend/convex/curriculumLessons/utils"; -import { runConvexProgram } from "@repo/backend/convex/lib/effect"; -import schema from "@repo/backend/convex/schema"; -import { convexModules } from "@repo/backend/convex/test.setup"; -import { createLearningGraphIdentityFromRoute } from "@repo/contents/_types/learning-graph"; -import { convexTest } from "convex-test"; -import { describe, expect, it } from "vitest"; - -const NOW = Date.parse("2026-01-01T00:00:00.000Z"); -const ARTICLE_ROUTE = "articles/politics/analytics-writes"; -const SUBJECT_ROUTE = "material/lesson/mathematics/vector/addition"; -const EXISTING_EXERCISE_ROUTE = - "material/practice/assessment/snbt/quantitative-knowledge/try-out-2026/set-1"; -const NEW_EXERCISE_ROUTE = - "material/practice/assessment/snbt/quantitative-knowledge/try-out-2026/set-2"; - -function getGraph(route: string) { - const graph = createLearningGraphIdentityFromRoute({ - locale: "en", - route, - }); - - if (!graph) { - throw new Error(`Expected graph identity for ${route}.`); - } - - return { - ...graph, - content_id: graph.assetId, - }; -} - -/** Enqueues one analytics row for the supplied graph content reference. */ -async function enqueueView( - ctx: MutationCtx, - input: ReturnType & { - readonly route: string; - readonly section: Doc<"contentViewAnalyticsQueue">["section"]; - }, - offsetMs = 0 -) { - await ctx.db.insert("contentViewAnalyticsQueue", { - ...input, - locale: "en", - partition: 0, - viewedAt: NOW + offsetMs, - }); -} - -describe("contents/helpers/writes", () => { - it("folds queued view deltas into existing and new popularity rows", async () => { - const t = convexTest(schema, convexModules); - - const ids = await t.mutation(async (ctx) => { - const article = getGraph(ARTICLE_ROUTE); - const subject = getGraph(SUBJECT_ROUTE); - const existingExercise = getGraph(EXISTING_EXERCISE_ROUTE); - const newExercise = getGraph(NEW_EXERCISE_ROUTE); - const bucketStart = getTrendingBucketStart(NOW); - - await ctx.db.insert("learningPopularity", { - ...article, - locale: "en", - section: "articles", - updatedAt: NOW - 1, - viewCount: 3, - }); - await ctx.db.insert("learningPopularity", { - ...subject, - locale: "en", - section: "material", - updatedAt: NOW - 1, - viewCount: 4, - }); - await ctx.db.insert("learningTrendingBuckets", { - ...subject, - bucketStart, - locale: "en", - section: "material", - updatedAt: NOW - 1, - viewCount: 5, - }); - await ctx.db.insert("learningPopularity", { - ...existingExercise, - locale: "en", - section: "material", - updatedAt: NOW - 1, - viewCount: 6, - }); - - await enqueueView(ctx, { - ...article, - route: ARTICLE_ROUTE, - section: "articles", - }); - await enqueueView(ctx, { - ...subject, - route: SUBJECT_ROUTE, - section: "material", - }); - await enqueueView(ctx, { - ...existingExercise, - route: EXISTING_EXERCISE_ROUTE, - section: "material", - }); - await enqueueView( - ctx, - { - ...existingExercise, - route: EXISTING_EXERCISE_ROUTE, - section: "material", - }, - 1 - ); - await enqueueView(ctx, { - ...newExercise, - route: NEW_EXERCISE_ROUTE, - section: "material", - }); - - const queueItems = await ctx.db - .query("contentViewAnalyticsQueue") - .collect(); - - await runConvexProgram( - applyContentAnalyticsBatch(ctx, { queueItems, updatedAt: NOW }) - ); - - return { article, existingExercise, newExercise, subject }; - }); - - const state = await t.query(async (ctx) => ({ - articleLearningPopularity: await ctx.db - .query("learningPopularity") - .withIndex("by_content_id", (q) => - q.eq("content_id", ids.article.content_id) - ) - .unique(), - existingExercisePopularity: await ctx.db - .query("learningPopularity") - .withIndex("by_content_id", (q) => - q.eq("content_id", ids.existingExercise.content_id) - ) - .unique(), - newExercisePopularity: await ctx.db - .query("learningPopularity") - .withIndex("by_content_id", (q) => - q.eq("content_id", ids.newExercise.content_id) - ) - .unique(), - subjectLearningPopularity: await ctx.db - .query("learningPopularity") - .withIndex("by_content_id", (q) => - q.eq("content_id", ids.subject.content_id) - ) - .unique(), - subjectTrendingBucket: await ctx.db - .query("learningTrendingBuckets") - .withIndex( - "by_section_and_locale_and_bucketStart_and_content_id", - (q) => - q - .eq("section", "material") - .eq("locale", "en") - .eq("bucketStart", getTrendingBucketStart(NOW)) - .eq("content_id", ids.subject.content_id) - ) - .unique(), - })); - - expect(state.articleLearningPopularity).toMatchObject({ - locale: "en", - section: "articles", - updatedAt: NOW, - viewCount: 4, - }); - expect(state.subjectLearningPopularity).toMatchObject({ - locale: "en", - section: "material", - updatedAt: NOW, - viewCount: 5, - }); - expect(state.subjectTrendingBucket).toMatchObject({ - updatedAt: NOW, - viewCount: 6, - }); - expect(state.existingExercisePopularity).toMatchObject({ - locale: "en", - section: "material", - updatedAt: NOW, - viewCount: 8, - }); - expect(state.newExercisePopularity).toMatchObject({ - locale: "en", - section: "material", - updatedAt: NOW, - viewCount: 1, - }); - }); -}); diff --git a/packages/backend/convex/contents/helpers/writes.ts b/packages/backend/convex/contents/helpers/writes.ts deleted file mode 100644 index cd1523f894..0000000000 --- a/packages/backend/convex/contents/helpers/writes.ts +++ /dev/null @@ -1,254 +0,0 @@ -import type { Doc } from "@repo/backend/convex/_generated/dataModel"; -import type { MutationCtx } from "@repo/backend/convex/_generated/server"; -import { toContentAnalyticsIoError } from "@repo/backend/convex/contents/analytics/spec"; -import { getTrendingBucketStart } from "@repo/backend/convex/curriculumLessons/utils"; -import type { Locale } from "@repo/backend/convex/lib/validators/contents"; -import { Effect } from "effect"; - -type QueuedContentView = Doc<"contentViewAnalyticsQueue">; -type AnalyticsGraphRef = Pick< - QueuedContentView, - | "alignmentId" - | "assetId" - | "conceptId" - | "content_id" - | "learningObjectId" - | "lensId" ->; - -/** Aggregated graph-view delta for one content asset and locale. */ -interface AnalyticsCount { - readonly locale: Locale; - readonly ref: AnalyticsGraphRef; - readonly section: QueuedContentView["section"]; - viewCount: number; -} - -/** Extracts persisted graph identity fields from one queued content view. */ -function getAnalyticsGraphRef(item: QueuedContentView): AnalyticsGraphRef { - return { - alignmentId: item.alignmentId, - assetId: item.assetId, - conceptId: item.conceptId, - content_id: item.content_id, - learningObjectId: item.learningObjectId, - lensId: item.lensId, - }; -} - -/** Increments one aggregated counter inside a mutable batch map. */ -function incrementCount( - map: Map, - item: QueuedContentView -) { - const existing = map.get(item.content_id); - - if (existing) { - existing.viewCount += 1; - return; - } - - map.set(item.content_id, { - locale: item.locale, - ref: getAnalyticsGraphRef(item), - section: item.section, - viewCount: 1, - }); -} - -/** Builds one analytics batch from append-only queued unique views. */ -function buildContentAnalyticsBatch(queueItems: readonly QueuedContentView[]) { - const learningPopularity = new Map(); - const learningTrendingBuckets = new Map< - string, - { - ref: AnalyticsGraphRef; - bucketStart: number; - locale: Locale; - section: QueuedContentView["section"]; - viewCount: number; - } - >(); - - for (const queueItem of queueItems) { - incrementCount(learningPopularity, queueItem); - - if (queueItem.section === "material") { - const bucketStart = getTrendingBucketStart(queueItem.viewedAt); - const bucketKey = `${queueItem.locale}:${bucketStart}:${queueItem.content_id}`; - const existingBucket = learningTrendingBuckets.get(bucketKey); - - if (existingBucket) { - existingBucket.viewCount += 1; - continue; - } - - learningTrendingBuckets.set(bucketKey, { - ref: getAnalyticsGraphRef(queueItem), - bucketStart, - locale: queueItem.locale, - section: queueItem.section, - viewCount: 1, - }); - } - } - - return { - learningPopularity, - learningTrendingBuckets, - }; -} - -/** Applies one graph-backed popularity delta to the learning read model. */ -const applyLearningPopularityDelta = Effect.fn( - "contents.analytics.applyLearningPopularityDelta" -)(function* ( - ctx: MutationCtx, - { - locale, - ref, - section, - updatedAt, - viewCount, - }: { - locale: Locale; - ref: AnalyticsGraphRef; - section: QueuedContentView["section"]; - updatedAt: number; - viewCount: number; - } -) { - const currentRow = yield* Effect.tryPromise({ - try: () => - ctx.db - .query("learningPopularity") - .withIndex("by_content_id", (q) => q.eq("content_id", ref.content_id)) - .unique(), - catch: toContentAnalyticsIoError, - }); - - if (!currentRow) { - yield* Effect.tryPromise({ - try: () => - ctx.db.insert("learningPopularity", { - ...ref, - locale, - section, - updatedAt, - viewCount, - }), - catch: toContentAnalyticsIoError, - }); - return; - } - - yield* Effect.tryPromise({ - try: () => - ctx.db.patch("learningPopularity", currentRow._id, { - ...ref, - locale, - section, - updatedAt, - viewCount: currentRow.viewCount + viewCount, - }), - catch: toContentAnalyticsIoError, - }); -}); - -/** Applies one locale/day learning trend delta to the derived table. */ -const applyLearningTrendingBucketDelta = Effect.fn( - "contents.analytics.applyLearningTrendingBucketDelta" -)(function* ( - ctx: MutationCtx, - { - bucketStart, - locale, - ref, - section, - updatedAt, - viewCount, - }: { - bucketStart: number; - locale: Locale; - ref: AnalyticsGraphRef; - section: QueuedContentView["section"]; - updatedAt: number; - viewCount: number; - } -) { - const currentRow = yield* Effect.tryPromise({ - try: () => - ctx.db - .query("learningTrendingBuckets") - .withIndex( - "by_section_and_locale_and_bucketStart_and_content_id", - (q) => - q - .eq("section", section) - .eq("locale", locale) - .eq("bucketStart", bucketStart) - .eq("content_id", ref.content_id) - ) - .unique(), - catch: toContentAnalyticsIoError, - }); - - if (!currentRow) { - yield* Effect.tryPromise({ - try: () => - ctx.db.insert("learningTrendingBuckets", { - ...ref, - bucketStart, - locale, - section, - updatedAt, - viewCount, - }), - catch: toContentAnalyticsIoError, - }); - return; - } - - yield* Effect.tryPromise({ - try: () => - ctx.db.patch("learningTrendingBuckets", currentRow._id, { - ...ref, - bucketStart, - locale, - section, - updatedAt, - viewCount: currentRow.viewCount + viewCount, - }), - catch: toContentAnalyticsIoError, - }); -}); - -/** Folds queued unique views into derived popularity tables. */ -export const applyContentAnalyticsBatch = Effect.fn( - "contents.analytics.applyContentAnalyticsBatch" -)(function* ( - ctx: MutationCtx, - { - queueItems, - updatedAt, - }: { - readonly queueItems: readonly Doc<"contentViewAnalyticsQueue">[]; - readonly updatedAt: number; - } -) { - const analyticsBatch = buildContentAnalyticsBatch(queueItems); - - for (const popularityDelta of analyticsBatch.learningPopularity.values()) { - yield* applyLearningPopularityDelta(ctx, { - ...popularityDelta, - updatedAt, - }); - } - - for (const bucketDelta of analyticsBatch.learningTrendingBuckets.values()) { - yield* applyLearningTrendingBucketDelta(ctx, { - ...bucketDelta, - updatedAt, - }); - } -}); diff --git a/packages/backend/convex/contents/metrics/apply.ts b/packages/backend/convex/contents/metrics/apply.ts new file mode 100644 index 0000000000..883d78a38d --- /dev/null +++ b/packages/backend/convex/contents/metrics/apply.ts @@ -0,0 +1,165 @@ +import type { Doc } from "@repo/backend/convex/_generated/dataModel"; +import type { MutationCtx } from "@repo/backend/convex/_generated/server"; +import { toContentAnalyticsIoError } from "@repo/backend/convex/contents/analytics/spec"; +import { + buildMetricsBatch, + type PopularityCounterDelta, + type PopularitySignalDelta, +} from "@repo/backend/convex/contents/metrics/batch"; +import { Effect } from "effect"; + +/** Applies one verified daily popularity signal delta. */ +const applySignal = Effect.fn("contents.metrics.applySignal")(function* ( + ctx: MutationCtx, + delta: PopularitySignalDelta & { readonly updatedAt: number } +) { + const currentRow = yield* Effect.tryPromise({ + try: () => + ctx.db + .query("learningPopularitySignals") + .withIndex( + "by_scopeMode_and_signalDay_and_content_id_and_contextKey", + (q) => + q + .eq("scopeMode", delta.scopeMode) + .eq("signalDay", delta.signalDay) + .eq("content_id", delta.ref.content_id) + .eq("contextKey", delta.context.contextKey) + ) + .unique(), + catch: toContentAnalyticsIoError, + }); + + if (!currentRow) { + yield* Effect.tryPromise({ + try: () => + ctx.db.insert("learningPopularitySignals", { + ...delta.ref, + ...delta.context, + description: delta.description, + locale: delta.locale, + materialDomain: delta.materialDomain, + route: delta.route, + section: delta.section, + scopeMode: delta.scopeMode, + signalDay: delta.signalDay, + sourcePath: delta.sourcePath, + title: delta.title, + updatedAt: delta.updatedAt, + viewCount: delta.viewCount, + }), + catch: toContentAnalyticsIoError, + }); + return; + } + + yield* Effect.tryPromise({ + try: () => + ctx.db.patch("learningPopularitySignals", currentRow._id, { + ...delta.ref, + ...delta.context, + description: delta.description, + locale: delta.locale, + materialDomain: delta.materialDomain, + route: delta.route, + section: delta.section, + scopeMode: delta.scopeMode, + signalDay: delta.signalDay, + sourcePath: delta.sourcePath, + title: delta.title, + updatedAt: delta.updatedAt, + viewCount: currentRow.viewCount + delta.viewCount, + }), + catch: toContentAnalyticsIoError, + }); +}); + +/** Applies one ranked popularity counter delta. */ +const applyCounter = Effect.fn("contents.metrics.applyCounter")(function* ( + ctx: MutationCtx, + delta: PopularityCounterDelta & { readonly updatedAt: number } +) { + const currentRow = yield* Effect.tryPromise({ + try: () => + ctx.db + .query("learningPopularityCounters") + .withIndex( + "by_windowKey_and_scopeMode_and_content_id_and_contextKey", + (q) => + q + .eq("windowKey", delta.windowKey) + .eq("scopeMode", delta.scopeMode) + .eq("content_id", delta.ref.content_id) + .eq("contextKey", delta.context.contextKey) + ) + .unique(), + catch: toContentAnalyticsIoError, + }); + + if (!currentRow) { + yield* Effect.tryPromise({ + try: () => + ctx.db.insert("learningPopularityCounters", { + ...delta.ref, + ...delta.context, + description: delta.description, + locale: delta.locale, + materialDomain: delta.materialDomain, + route: delta.route, + score: delta.viewCount, + section: delta.section, + scopeMode: delta.scopeMode, + sourcePath: delta.sourcePath, + title: delta.title, + updatedAt: delta.updatedAt, + windowKey: delta.windowKey, + }), + catch: toContentAnalyticsIoError, + }); + return; + } + + yield* Effect.tryPromise({ + try: () => + ctx.db.patch("learningPopularityCounters", currentRow._id, { + ...delta.ref, + ...delta.context, + description: delta.description, + locale: delta.locale, + materialDomain: delta.materialDomain, + route: delta.route, + score: currentRow.score + delta.viewCount, + section: delta.section, + scopeMode: delta.scopeMode, + sourcePath: delta.sourcePath, + title: delta.title, + updatedAt: delta.updatedAt, + windowKey: delta.windowKey, + }), + catch: toContentAnalyticsIoError, + }); +}); + +/** Folds queued unique views into derived popularity tables. */ +export const applyContentAnalyticsBatch = Effect.fn( + "contents.metrics.applyContentAnalyticsBatch" +)(function* ( + ctx: MutationCtx, + { + queueItems, + updatedAt, + }: { + readonly queueItems: readonly Doc<"learningEngagementQueue">[]; + readonly updatedAt: number; + } +) { + const batch = buildMetricsBatch({ queueItems, updatedAt }); + + for (const signal of batch.signals.values()) { + yield* applySignal(ctx, { ...signal, updatedAt }); + } + + for (const counter of batch.counters.values()) { + yield* applyCounter(ctx, { ...counter, updatedAt }); + } +}); diff --git a/packages/backend/convex/contents/metrics/batch.ts b/packages/backend/convex/contents/metrics/batch.ts new file mode 100644 index 0000000000..3ec3bd38dd --- /dev/null +++ b/packages/backend/convex/contents/metrics/batch.ts @@ -0,0 +1,177 @@ +import type { Doc } from "@repo/backend/convex/_generated/dataModel"; +import { + getPopularitySignalDay, + isFinitePopularityWindow, + isPopularitySignalInWindow, + type LearningPopularityWindow, + learningPopularityWindowValues, +} from "@repo/backend/convex/contents/popularity"; + +type QueuedLearningEngagement = Doc<"learningEngagementQueue">; +type AnalyticsGraphRef = Pick< + QueuedLearningEngagement, + | "alignmentId" + | "assetId" + | "conceptId" + | "content_id" + | "learningObjectId" + | "lensId" +>; + +/** Extracts persisted graph identity fields from one queued content view. */ +function getAnalyticsGraphRef( + item: QueuedLearningEngagement +): AnalyticsGraphRef { + return { + alignmentId: item.alignmentId, + assetId: item.assetId, + conceptId: item.conceptId, + content_id: item.content_id, + learningObjectId: item.learningObjectId, + lensId: item.lensId, + }; +} + +/** Extracts verified learning-context storage fields from one queued view. */ +function getAnalyticsContext(item: QueuedLearningEngagement) { + return { + contextKey: item.contextKey, + contextMaterialKey: item.contextMaterialKey, + contextMode: item.contextMode, + contextNodeKey: item.contextNodeKey, + contextParentPath: item.contextParentPath, + contextProgramKey: item.contextProgramKey, + contextPublicPath: item.contextPublicPath, + contextSourcePath: item.contextSourcePath, + }; +} + +/** Creates the first aggregate row for one queued engagement item. */ +function createAnalyticsCount(item: QueuedLearningEngagement) { + return { + context: getAnalyticsContext(item), + description: item.description, + locale: item.locale, + materialDomain: item.materialDomain, + ref: getAnalyticsGraphRef(item), + route: item.route, + section: item.section, + scopeMode: item.scopeMode, + sourcePath: item.sourcePath, + title: item.title, + viewCount: 1, + }; +} + +/** Creates the first daily popularity signal delta for one queued view. */ +function createPopularitySignalDelta(item: QueuedLearningEngagement) { + return { + ...createAnalyticsCount(item), + signalDay: getPopularitySignalDay(item.viewedAt), + }; +} + +/** Creates the first configured-window counter delta for one queued view. */ +function createPopularityCounterDelta( + item: QueuedLearningEngagement, + windowKey: LearningPopularityWindow +) { + return { + ...createAnalyticsCount(item), + windowKey, + }; +} + +/** Returns whether one queued event should update the requested counter row. */ +function shouldApplyPopularityCounterDelta({ + signalDay, + updatedAt, + windowKey, +}: { + readonly signalDay: number; + readonly updatedAt: number; + readonly windowKey: LearningPopularityWindow; +}) { + if (!isFinitePopularityWindow(windowKey)) { + return true; + } + + return isPopularitySignalInWindow({ + signalDay, + timestamp: updatedAt, + windowKey, + }); +} + +/** Aggregated daily popularity signal delta derived from queued view docs. */ +export type PopularitySignalDelta = ReturnType< + typeof createPopularitySignalDelta +>; + +/** Aggregated configured-window counter delta derived from queued view docs. */ +export type PopularityCounterDelta = ReturnType< + typeof createPopularityCounterDelta +>; + +/** Builds one analytics batch from append-only queued unique views. */ +export function buildMetricsBatch({ + queueItems, + updatedAt, +}: { + readonly queueItems: readonly QueuedLearningEngagement[]; + readonly updatedAt: number; +}) { + const counters = new Map(); + const signals = new Map(); + + for (const queueItem of queueItems) { + const signalDay = getPopularitySignalDay(queueItem.viewedAt); + const signalKey = [ + queueItem.scopeMode, + signalDay, + queueItem.content_id, + queueItem.contextKey, + ].join(":"); + const signalCount = signals.get(signalKey); + + if (signalCount) { + signalCount.viewCount += 1; + } else { + signals.set(signalKey, createPopularitySignalDelta(queueItem)); + } + + for (const windowKey of learningPopularityWindowValues) { + if ( + !shouldApplyPopularityCounterDelta({ + signalDay, + updatedAt, + windowKey, + }) + ) { + continue; + } + + const counterKey = [ + windowKey, + queueItem.scopeMode, + queueItem.content_id, + queueItem.contextKey, + ].join(":"); + const counterCount = counters.get(counterKey); + + if (counterCount) { + counterCount.viewCount += 1; + } else { + counters.set( + counterKey, + createPopularityCounterDelta(queueItem, windowKey) + ); + } + } + } + + return { + counters, + signals, + }; +} diff --git a/packages/backend/convex/contents/metrics/refresh.ts b/packages/backend/convex/contents/metrics/refresh.ts new file mode 100644 index 0000000000..65d6c92f9a --- /dev/null +++ b/packages/backend/convex/contents/metrics/refresh.ts @@ -0,0 +1,250 @@ +import type { Doc } from "@repo/backend/convex/_generated/dataModel"; +import type { MutationCtx } from "@repo/backend/convex/_generated/server"; +import type { + RefreshLearningPopularityWindowPageArgs, + RefreshLearningPopularityWindowPageResult, +} from "@repo/backend/convex/contents/analytics/spec"; +import { toContentAnalyticsIoError } from "@repo/backend/convex/contents/analytics/spec"; +import { LEARNING_POPULARITY_REFRESH_BATCH_SIZE } from "@repo/backend/convex/contents/constants"; +import { + getFinitePopularityWindows, + getPopularitySignalDay, + getPopularityWindowDayCount, + getPopularityWindowStartDay, + isFinitePopularityWindow, + learningPopularityScopeValues, +} from "@repo/backend/convex/contents/popularity"; +import type { FunctionReference } from "convex/server"; +import { Clock, Effect } from "effect"; + +type PopularityCounter = Doc<"learningPopularityCounters">; +type PopularitySignal = Doc<"learningPopularitySignals">; + +/** Generated internal mutation reference accepted by Convex refresh scheduling. */ +type RefreshLearningPopularityWindowPageReference = FunctionReference< + "mutation", + "internal", + RefreshLearningPopularityWindowPageArgs, + RefreshLearningPopularityWindowPageResult +>; + +/** Loads bounded daily signal rows for one counter and finite window. */ +const loadPopularitySignals = Effect.fn( + "contents.metrics.loadPopularitySignals" +)(function* ( + ctx: MutationCtx, + counter: PopularityCounter, + windowKey: Exclude, + timestamp: number +) { + const currentDay = getPopularitySignalDay(timestamp); + const startDay = getPopularityWindowStartDay(windowKey, timestamp); + const dayCount = getPopularityWindowDayCount(windowKey); + + return yield* Effect.tryPromise({ + try: () => + ctx.db + .query("learningPopularitySignals") + .withIndex( + "by_scopeMode_and_content_id_and_contextKey_and_signalDay", + (q) => + q + .eq("scopeMode", counter.scopeMode) + .eq("content_id", counter.content_id) + .eq("contextKey", counter.contextKey) + .gte("signalDay", startDay) + .lte("signalDay", currentDay) + ) + .take(dayCount), + catch: toContentAnalyticsIoError, + }); +}); + +/** Recomputes one finite-window counter from durable daily signal rows. */ +const recomputePopularityCounter = Effect.fn( + "contents.metrics.recomputePopularityCounter" +)(function* (ctx: MutationCtx, counter: PopularityCounter, timestamp: number) { + if (!isFinitePopularityWindow(counter.windowKey)) { + return { + latestSignal: null, + score: counter.score, + }; + } + + const signals = yield* loadPopularitySignals( + ctx, + counter, + counter.windowKey, + timestamp + ); + let latestSignal: PopularitySignal | null = null; + let score = 0; + + for (const signal of signals) { + score += signal.viewCount; + latestSignal = signal; + } + + return { + latestSignal, + score, + }; +}); + +/** Applies a recomputed finite-window score to one popularity counter row. */ +const refreshPopularityCounter = Effect.fn( + "contents.metrics.refreshPopularityCounter" +)(function* (ctx: MutationCtx, counter: PopularityCounter, timestamp: number) { + const refresh = yield* recomputePopularityCounter(ctx, counter, timestamp); + + if (refresh.score <= 0) { + yield* Effect.tryPromise({ + try: () => ctx.db.delete(counter._id), + catch: toContentAnalyticsIoError, + }); + + return { + removed: true, + refreshed: false, + }; + } + + const latestSignal = refresh.latestSignal; + + yield* Effect.tryPromise({ + try: () => + ctx.db.patch(counter._id, { + alignmentId: latestSignal?.alignmentId ?? counter.alignmentId, + assetId: latestSignal?.assetId ?? counter.assetId, + conceptId: latestSignal?.conceptId ?? counter.conceptId, + contextMaterialKey: + latestSignal?.contextMaterialKey ?? counter.contextMaterialKey, + contextMode: latestSignal?.contextMode ?? counter.contextMode, + contextNodeKey: latestSignal?.contextNodeKey ?? counter.contextNodeKey, + contextParentPath: + latestSignal?.contextParentPath ?? counter.contextParentPath, + contextProgramKey: + latestSignal?.contextProgramKey ?? counter.contextProgramKey, + contextPublicPath: + latestSignal?.contextPublicPath ?? counter.contextPublicPath, + contextSourcePath: + latestSignal?.contextSourcePath ?? counter.contextSourcePath, + description: latestSignal?.description ?? counter.description, + learningObjectId: + latestSignal?.learningObjectId ?? counter.learningObjectId, + lensId: latestSignal?.lensId ?? counter.lensId, + materialDomain: latestSignal?.materialDomain ?? counter.materialDomain, + route: latestSignal?.route ?? counter.route, + score: refresh.score, + sourcePath: latestSignal?.sourcePath ?? counter.sourcePath, + title: latestSignal?.title ?? counter.title, + updatedAt: timestamp, + }), + catch: toContentAnalyticsIoError, + }); + + return { + removed: false, + refreshed: true, + }; +}); + +/** Schedules bounded refresh work for every finite popularity window and scope. */ +export const scheduleLearningPopularityRefreshes = Effect.fn( + "contents.metrics.scheduleLearningPopularityRefreshes" +)(function* ( + ctx: MutationCtx, + refreshWindowPage: RefreshLearningPopularityWindowPageReference +) { + let scheduledWindows = 0; + + for (const scopeMode of learningPopularityScopeValues) { + for (const windowKey of getFinitePopularityWindows()) { + yield* Effect.tryPromise({ + try: () => + ctx.scheduler.runAfter(0, refreshWindowPage, { + scopeMode, + windowKey, + }), + catch: toContentAnalyticsIoError, + }); + + scheduledWindows += 1; + } + } + + return { + scheduledWindows, + }; +}); + +/** Refreshes one bounded page of finite-window popularity counters. */ +export const refreshLearningPopularityWindowPage = Effect.fn( + "contents.metrics.refreshLearningPopularityWindowPage" +)(function* ( + ctx: MutationCtx, + args: RefreshLearningPopularityWindowPageArgs, + refreshWindowPage: RefreshLearningPopularityWindowPageReference +) { + if (!isFinitePopularityWindow(args.windowKey)) { + return { + continueCursor: args.cursor ?? "", + isDone: true, + refreshedCounters: 0, + removedCounters: 0, + skipped: true, + }; + } + + const timestamp = yield* Clock.currentTimeMillis; + const page = yield* Effect.tryPromise({ + try: () => + ctx.db + .query("learningPopularityCounters") + .withIndex( + "by_windowKey_and_scopeMode_and_content_id_and_contextKey", + (q) => + q.eq("windowKey", args.windowKey).eq("scopeMode", args.scopeMode) + ) + .paginate({ + cursor: args.cursor ?? null, + numItems: LEARNING_POPULARITY_REFRESH_BATCH_SIZE, + }), + catch: toContentAnalyticsIoError, + }); + + let refreshedCounters = 0; + let removedCounters = 0; + + for (const counter of page.page) { + const result = yield* refreshPopularityCounter(ctx, counter, timestamp); + + if (result.refreshed) { + refreshedCounters += 1; + } + + if (result.removed) { + removedCounters += 1; + } + } + + if (!page.isDone) { + yield* Effect.tryPromise({ + try: () => + ctx.scheduler.runAfter(0, refreshWindowPage, { + cursor: page.continueCursor, + scopeMode: args.scopeMode, + windowKey: args.windowKey, + }), + catch: toContentAnalyticsIoError, + }); + } + + return { + continueCursor: page.continueCursor, + isDone: page.isDone, + refreshedCounters, + removedCounters, + skipped: false, + }; +}); diff --git a/packages/backend/convex/contents/mutations/analytics.test.ts b/packages/backend/convex/contents/mutations/analytics.test.ts index f8a0cfb348..43598761df 100644 --- a/packages/backend/convex/contents/mutations/analytics.test.ts +++ b/packages/backend/convex/contents/mutations/analytics.test.ts @@ -6,8 +6,14 @@ import { CONTENT_ANALYTICS_LEASE_DURATION_MS, CONTENT_ANALYTICS_PARTITIONS, } from "@repo/backend/convex/contents/constants"; -import { getTrendingBucketStart } from "@repo/backend/convex/curriculumLessons/utils"; +import { + getDefaultPopularityWindow, + getLifetimePopularityWindow, + getPopularitySignalDay, + POPULARITY_DAY_MS, +} from "@repo/backend/convex/contents/popularity"; import schema from "@repo/backend/convex/schema"; +import { registerLearningPopularityAggregate } from "@repo/backend/convex/test.helpers"; import { convexModules } from "@repo/backend/convex/test.setup"; import { logger } from "@repo/backend/convex/utils/logger"; import { createLearningGraphIdentityFromRoute } from "@repo/contents/_types/learning-graph"; @@ -17,7 +23,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const NOW = Date.parse("2026-01-01T00:00:00.000Z"); const ARTICLE_ROUTE = "articles/politics/dynastic-politics-asian-values"; const SUBJECT_ROUTE = "material/lesson/mathematics/vector/addition"; +const canonicalContext = { + contextKey: "canonical", + contextMode: "canonical", +} as const; + +/** + * Builds a Convex test instance with the popularity aggregate registered so + * analytics counter writes exercise the same trigger path as production. + */ +function createAnalyticsConvexTest() { + const t = convexTest(schema, convexModules); + registerLearningPopularityAggregate(t); + return t; +} +/** Builds a graph identity fixture and fails fast when the route is invalid. */ function getGraph(route: string) { const graph = createLearningGraphIdentityFromRoute({ locale: "en", @@ -103,12 +124,20 @@ async function enqueueSubjectViews( count: number ) { for (let index = 0; index < count; index += 1) { - await ctx.db.insert("contentViewAnalyticsQueue", { + await ctx.db.insert("learningEngagementQueue", { ...subject, + ...canonicalContext, + description: "Subject description", + insertedAt: NOW + index, locale: "en", + materialDomain: "mathematics", partition: 0, route: SUBJECT_ROUTE, section: "material", + scopeMode: "global", + sourcePath: SUBJECT_ROUTE, + title: "Vector Addition", + viewerKey: `device:subject-${index}`, viewedAt: NOW + index, }); } @@ -127,7 +156,7 @@ describe("contents/mutations/analytics", () => { }); it("does not schedule partition work when every queue is empty", async () => { - const t = convexTest(schema, convexModules); + const t = createAnalyticsConvexTest(); const result = await t.mutation( internal.contents.mutations.analytics.scheduleContentAnalyticsPartitions @@ -144,24 +173,39 @@ describe("contents/mutations/analytics", () => { }); it("schedules one worker attempt per non-empty analytics partition", async () => { - const t = convexTest(schema, convexModules); + const t = createAnalyticsConvexTest(); await t.mutation(async (ctx) => { const { article, subject } = await insertAnalyticsContent(ctx); - await ctx.db.insert("contentViewAnalyticsQueue", { + await ctx.db.insert("learningEngagementQueue", { ...article, + ...canonicalContext, + description: "Article description", + insertedAt: NOW, locale: "en", partition: 0, route: ARTICLE_ROUTE, section: "articles", + scopeMode: "global", + sourcePath: ARTICLE_ROUTE, + title: "Dynastic Politics", + viewerKey: "device:article", viewedAt: NOW, }); - await ctx.db.insert("contentViewAnalyticsQueue", { + await ctx.db.insert("learningEngagementQueue", { ...subject, + ...canonicalContext, + description: "Subject description", + insertedAt: NOW, locale: "en", + materialDomain: "mathematics", partition: 3, route: SUBJECT_ROUTE, section: "material", + scopeMode: "global", + sourcePath: SUBJECT_ROUTE, + title: "Vector Addition", + viewerKey: "device:subject", viewedAt: NOW, }); }); @@ -183,7 +227,7 @@ describe("contents/mutations/analytics", () => { }); it("creates and leases a partition once while the lease is active", async () => { - const t = convexTest(schema, convexModules); + const t = createAnalyticsConvexTest(); await t.mutation(async (ctx) => { const { subject } = await insertAnalyticsContent(ctx); @@ -225,7 +269,7 @@ describe("contents/mutations/analytics", () => { }); it("does not create a lease when a partition queue is empty", async () => { - const t = convexTest(schema, convexModules); + const t = createAnalyticsConvexTest(); const result = await t.mutation( internal.contents.mutations.analytics.scheduleContentAnalyticsPartition, @@ -244,25 +288,40 @@ describe("contents/mutations/analytics", () => { }); it("drains queued views into popularity tables and releases the lease", async () => { - const t = convexTest(schema, convexModules); + const t = createAnalyticsConvexTest(); const ids = await t.mutation(async (ctx) => { const content = await insertAnalyticsContent(ctx); await insertActivePartition(ctx); - await ctx.db.insert("contentViewAnalyticsQueue", { + await ctx.db.insert("learningEngagementQueue", { ...content.article, + ...canonicalContext, + description: "Article description", + insertedAt: NOW, locale: "en", partition: 0, route: ARTICLE_ROUTE, section: "articles", + scopeMode: "global", + sourcePath: ARTICLE_ROUTE, + title: "Dynastic Politics", + viewerKey: "device:article", viewedAt: NOW, }); - await ctx.db.insert("contentViewAnalyticsQueue", { + await ctx.db.insert("learningEngagementQueue", { ...content.subject, + ...canonicalContext, + description: "Subject description", + insertedAt: NOW, locale: "en", + materialDomain: "mathematics", partition: 0, route: SUBJECT_ROUTE, section: "material", + scopeMode: "global", + sourcePath: SUBJECT_ROUTE, + title: "Vector Addition", + viewerKey: "device:subject", viewedAt: NOW, }); await enqueueSubjectViews(ctx, content.subject, 1); @@ -276,33 +335,45 @@ describe("contents/mutations/analytics", () => { ); const state = await t.query(async (ctx) => ({ - articleLearningPopularity: await ctx.db - .query("learningPopularity") - .withIndex("by_content_id", (q) => - q.eq("content_id", ids.article.content_id) + articlePopularityCounter: await ctx.db + .query("learningPopularityCounters") + .withIndex( + "by_windowKey_and_scopeMode_and_content_id_and_contextKey", + (q) => + q + .eq("windowKey", getDefaultPopularityWindow()) + .eq("scopeMode", "global") + .eq("content_id", ids.article.content_id) + .eq("contextKey", canonicalContext.contextKey) ) .unique(), partitionRow: await ctx.db .query("contentAnalyticsPartitions") .withIndex("by_partition", (q) => q.eq("partition", 0)) .unique(), - queueItems: await ctx.db.query("contentViewAnalyticsQueue").collect(), - subjectLearningPopularity: await ctx.db - .query("learningPopularity") - .withIndex("by_content_id", (q) => - q.eq("content_id", ids.subject.content_id) + queueItems: await ctx.db.query("learningEngagementQueue").collect(), + subjectPopularityCounter: await ctx.db + .query("learningPopularityCounters") + .withIndex( + "by_windowKey_and_scopeMode_and_content_id_and_contextKey", + (q) => + q + .eq("windowKey", getDefaultPopularityWindow()) + .eq("scopeMode", "global") + .eq("content_id", ids.subject.content_id) + .eq("contextKey", canonicalContext.contextKey) ) .unique(), - subjectTrendingBucket: await ctx.db - .query("learningTrendingBuckets") + subjectPopularitySignal: await ctx.db + .query("learningPopularitySignals") .withIndex( - "by_section_and_locale_and_bucketStart_and_content_id", + "by_scopeMode_and_signalDay_and_content_id_and_contextKey", (q) => q - .eq("section", "material") - .eq("locale", "en") - .eq("bucketStart", getTrendingBucketStart(NOW)) + .eq("scopeMode", "global") + .eq("signalDay", getPopularitySignalDay(NOW)) .eq("content_id", ids.subject.content_id) + .eq("contextKey", canonicalContext.contextKey) ) .unique(), })); @@ -313,24 +384,27 @@ describe("contents/mutations/analytics", () => { processed: 3, skipped: false, }); - expect(state.articleLearningPopularity).toMatchObject({ + expect(state.articlePopularityCounter).toMatchObject({ content_id: ids.article.content_id, locale: "en", + score: 1, section: "articles", + scopeMode: "global", updatedAt: NOW, - viewCount: 1, }); - expect(state.subjectLearningPopularity).toMatchObject({ + expect(state.subjectPopularityCounter).toMatchObject({ content_id: ids.subject.content_id, locale: "en", + score: 2, section: "material", + scopeMode: "global", updatedAt: NOW, - viewCount: 2, }); - expect(state.subjectTrendingBucket).toMatchObject({ - bucketStart: getTrendingBucketStart(NOW), + expect(state.subjectPopularitySignal).toMatchObject({ content_id: ids.subject.content_id, locale: "en", + scopeMode: "global", + signalDay: getPopularitySignalDay(NOW), updatedAt: NOW, viewCount: 2, }); @@ -343,8 +417,90 @@ describe("contents/mutations/analytics", () => { expect(state.queueItems).toEqual([]); }); + it("keeps stale queue rows out of finite windows while preserving lifetime", async () => { + const t = createAnalyticsConvexTest(); + const staleViewedAt = NOW - 8 * POPULARITY_DAY_MS; + const subject = await t.mutation(async (ctx) => { + const { subject } = await insertAnalyticsContent(ctx); + await insertActivePartition(ctx); + await ctx.db.insert("learningEngagementQueue", { + ...subject, + ...canonicalContext, + description: "Subject description", + insertedAt: NOW, + locale: "en", + materialDomain: "mathematics", + partition: 0, + route: SUBJECT_ROUTE, + section: "material", + scopeMode: "global", + sourcePath: SUBJECT_ROUTE, + title: "Vector Addition", + viewerKey: "device:stale-subject", + viewedAt: staleViewedAt, + }); + + return subject; + }); + + await t.mutation( + internal.contents.mutations.analytics.processContentAnalyticsPartition, + { leaseVersion: 1, partition: 0 } + ); + + const state = await t.query(async (ctx) => ({ + finiteCounter: await ctx.db + .query("learningPopularityCounters") + .withIndex( + "by_windowKey_and_scopeMode_and_content_id_and_contextKey", + (q) => + q + .eq("windowKey", getDefaultPopularityWindow()) + .eq("scopeMode", "global") + .eq("content_id", subject.content_id) + .eq("contextKey", canonicalContext.contextKey) + ) + .unique(), + lifetimeCounter: await ctx.db + .query("learningPopularityCounters") + .withIndex( + "by_windowKey_and_scopeMode_and_content_id_and_contextKey", + (q) => + q + .eq("windowKey", getLifetimePopularityWindow()) + .eq("scopeMode", "global") + .eq("content_id", subject.content_id) + .eq("contextKey", canonicalContext.contextKey) + ) + .unique(), + signal: await ctx.db + .query("learningPopularitySignals") + .withIndex( + "by_scopeMode_and_signalDay_and_content_id_and_contextKey", + (q) => + q + .eq("scopeMode", "global") + .eq("signalDay", getPopularitySignalDay(staleViewedAt)) + .eq("content_id", subject.content_id) + .eq("contextKey", canonicalContext.contextKey) + ) + .unique(), + })); + + expect(state.finiteCounter).toBeNull(); + expect(state.lifetimeCounter).toMatchObject({ + content_id: subject.content_id, + score: 1, + windowKey: getLifetimePopularityWindow(), + }); + expect(state.signal).toMatchObject({ + signalDay: getPopularitySignalDay(staleViewedAt), + viewCount: 1, + }); + }); + it("reclaims expired leases and schedules the next worker", async () => { - const t = convexTest(schema, convexModules); + const t = createAnalyticsConvexTest(); await t.mutation(async (ctx) => { const { subject } = await insertAnalyticsContent(ctx); @@ -382,7 +538,7 @@ describe("contents/mutations/analytics", () => { }); it("releases an active lease when the partition queue is empty", async () => { - const t = convexTest(schema, convexModules); + const t = createAnalyticsConvexTest(); await t.mutation(async (ctx) => { await insertActivePartition(ctx); @@ -416,7 +572,7 @@ describe("contents/mutations/analytics", () => { }); it("continues a full partition batch without releasing the lease", async () => { - const t = convexTest(schema, convexModules); + const t = createAnalyticsConvexTest(); const subject = await t.mutation(async (ctx) => { const { subject } = await insertAnalyticsContent(ctx); @@ -435,14 +591,20 @@ describe("contents/mutations/analytics", () => { .query("contentAnalyticsPartitions") .withIndex("by_partition", (q) => q.eq("partition", 0)) .unique(), - queueItems: await ctx.db.query("contentViewAnalyticsQueue").collect(), + queueItems: await ctx.db.query("learningEngagementQueue").collect(), scheduledJobs: await ctx.db.system .query("_scheduled_functions") .collect(), - subjectLearningPopularity: await ctx.db - .query("learningPopularity") - .withIndex("by_content_id", (q) => - q.eq("content_id", subject.content_id) + subjectPopularityCounter: await ctx.db + .query("learningPopularityCounters") + .withIndex( + "by_windowKey_and_scopeMode_and_content_id_and_contextKey", + (q) => + q + .eq("windowKey", getDefaultPopularityWindow()) + .eq("scopeMode", "global") + .eq("content_id", subject.content_id) + .eq("contextKey", canonicalContext.contextKey) ) .unique(), })); @@ -462,16 +624,17 @@ describe("contents/mutations/analytics", () => { expect(state.scheduledJobs.map((job) => job.args[0])).toEqual([ { leaseVersion: 1, partition: 0 }, ]); - expect(state.subjectLearningPopularity).toMatchObject({ + expect(state.subjectPopularityCounter).toMatchObject({ content_id: subject.content_id, locale: "en", + score: CONTENT_ANALYTICS_BATCH_SIZE, section: "material", - viewCount: CONTENT_ANALYTICS_BATCH_SIZE, + scopeMode: "global", }); }); it("skips missing leases", async () => { - const t = convexTest(schema, convexModules); + const t = createAnalyticsConvexTest(); await expect( t.mutation( @@ -487,7 +650,7 @@ describe("contents/mutations/analytics", () => { }); it("skips stale lease versions", async () => { - const t = convexTest(schema, convexModules); + const t = createAnalyticsConvexTest(); await t.mutation(async (ctx) => { await insertActivePartition(ctx, { leaseVersion: 2 }); @@ -507,7 +670,7 @@ describe("contents/mutations/analytics", () => { }); it("skips expired leases", async () => { - const t = convexTest(schema, convexModules); + const t = createAnalyticsConvexTest(); await t.mutation(async (ctx) => { await insertActivePartition(ctx, { leaseExpiresAt: NOW - 1 }); @@ -527,7 +690,7 @@ describe("contents/mutations/analytics", () => { }); it("rejects unknown partitions with a tagged Convex error", async () => { - const t = convexTest(schema, convexModules); + const t = createAnalyticsConvexTest(); await expect( t.mutation( diff --git a/packages/backend/convex/contents/mutations/analytics.ts b/packages/backend/convex/contents/mutations/analytics.ts index 6ae9722d42..f5972a70fc 100644 --- a/packages/backend/convex/contents/mutations/analytics.ts +++ b/packages/backend/convex/contents/mutations/analytics.ts @@ -1,6 +1,5 @@ import { internal } from "@repo/backend/convex/_generated/api"; import { - type ContentAnalyticsSchedulerTargets, claimContentAnalyticsPartition, processClaimedContentAnalyticsPartition, scheduleAllContentAnalyticsPartitions, @@ -18,20 +17,16 @@ import { import { internalMutation } from "@repo/backend/convex/functions"; import { runConvexProgram } from "@repo/backend/convex/lib/effect"; -const schedulerTargets: ContentAnalyticsSchedulerTargets = { - processPartition: - internal.contents.mutations.analytics.processContentAnalyticsPartition, - schedulePartition: - internal.contents.mutations.analytics.scheduleContentAnalyticsPartition, -}; - /** Schedules one worker attempt per analytics partition. */ export const scheduleContentAnalyticsPartitions = internalMutation({ args: {}, returns: scheduleContentAnalyticsPartitionsResultValidator, handler: async (ctx): Promise => await runConvexProgram( - scheduleAllContentAnalyticsPartitions(ctx, schedulerTargets) + scheduleAllContentAnalyticsPartitions( + ctx, + internal.contents.mutations.analytics.scheduleContentAnalyticsPartition + ) ), }); @@ -44,7 +39,11 @@ export const scheduleContentAnalyticsPartition = internalMutation({ args ): Promise => await runConvexProgram( - claimContentAnalyticsPartition(ctx, args, schedulerTargets) + claimContentAnalyticsPartition( + ctx, + args, + internal.contents.mutations.analytics.processContentAnalyticsPartition + ) ), }); @@ -54,6 +53,10 @@ export const processContentAnalyticsPartition = internalMutation({ returns: processContentAnalyticsPartitionResultValidator, handler: async (ctx, args): Promise => await runConvexProgram( - processClaimedContentAnalyticsPartition(ctx, args, schedulerTargets) + processClaimedContentAnalyticsPartition( + ctx, + args, + internal.contents.mutations.analytics.processContentAnalyticsPartition + ) ), }); diff --git a/packages/backend/convex/contents/mutations/popularity.test.ts b/packages/backend/convex/contents/mutations/popularity.test.ts new file mode 100644 index 0000000000..4516bdff77 --- /dev/null +++ b/packages/backend/convex/contents/mutations/popularity.test.ts @@ -0,0 +1,168 @@ +import { internal } from "@repo/backend/convex/_generated/api"; +import type { MutationCtx } from "@repo/backend/convex/_generated/server"; +import { + getPopularitySignalDay, + POPULARITY_DAY_MS, +} from "@repo/backend/convex/contents/popularity"; +import { learningPopularityRankings } from "@repo/backend/convex/contents/rankings"; +import schema from "@repo/backend/convex/schema"; +import { registerLearningPopularityAggregate } from "@repo/backend/convex/test.helpers"; +import { convexModules } from "@repo/backend/convex/test.setup"; +import { createLearningGraphIdentityFromRoute } from "@repo/contents/_types/learning-graph"; +import { convexTest } from "convex-test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const NOW = Date.parse("2026-01-08T12:00:00.000Z"); +const ARTICLE_ROUTE = "articles/politics/dynastic-politics-asian-values"; +const SUBJECT_ROUTE = "material/lesson/mathematics/vector/addition"; + +/** Builds a Convex test instance with production popularity triggers enabled. */ +function createPopularityConvexTest() { + const t = convexTest(schema, convexModules); + registerLearningPopularityAggregate(t); + return t; +} + +/** Builds a graph identity fixture and fails fast when the route is invalid. */ +function getGraph(route: string) { + const graph = createLearningGraphIdentityFromRoute({ + locale: "en", + route, + }); + + if (!graph) { + throw new Error(`Expected graph identity for ${route}.`); + } + + return { + ...graph, + content_id: graph.assetId, + }; +} + +/** Inserts the stale and current popularity rows used by refresh behavior tests. */ +async function insertPopularityRefreshRows(ctx: MutationCtx) { + const article = getGraph(ARTICLE_ROUTE); + const subject = getGraph(SUBJECT_ROUTE); + const currentSignalDay = getPopularitySignalDay(NOW); + const expiredSignalDay = currentSignalDay - 8 * POPULARITY_DAY_MS; + + const subjectCounterId = await ctx.db.insert("learningPopularityCounters", { + ...subject, + contextKey: "canonical", + contextMode: "canonical", + description: "Stale subject description", + locale: "en", + materialDomain: "mathematics", + route: SUBJECT_ROUTE, + score: 99, + section: "material", + scopeMode: "global", + sourcePath: SUBJECT_ROUTE, + title: "Stale Vector Addition", + updatedAt: NOW - POPULARITY_DAY_MS, + windowKey: "7d", + }); + const articleCounterId = await ctx.db.insert("learningPopularityCounters", { + ...article, + contextKey: "canonical", + contextMode: "canonical", + description: "Expired article description", + locale: "en", + route: ARTICLE_ROUTE, + score: 5, + section: "articles", + scopeMode: "global", + sourcePath: ARTICLE_ROUTE, + title: "Expired Dynastic Politics", + updatedAt: NOW - POPULARITY_DAY_MS, + windowKey: "7d", + }); + const subjectCounter = await ctx.db.get(subjectCounterId); + const articleCounter = await ctx.db.get(articleCounterId); + + if (!(subjectCounter && articleCounter)) { + throw new Error( + "Expected popularity counters to exist for aggregate setup." + ); + } + + await learningPopularityRankings.insert(ctx, subjectCounter); + await learningPopularityRankings.insert(ctx, articleCounter); + + await ctx.db.insert("learningPopularitySignals", { + ...subject, + contextKey: "canonical", + contextMode: "canonical", + description: "Current subject description", + locale: "en", + materialDomain: "mathematics", + route: SUBJECT_ROUTE, + scopeMode: "global", + section: "material", + signalDay: currentSignalDay, + sourcePath: SUBJECT_ROUTE, + title: "Current Vector Addition", + updatedAt: NOW, + viewCount: 2, + }); + await ctx.db.insert("learningPopularitySignals", { + ...article, + contextKey: "canonical", + contextMode: "canonical", + description: "Old article description", + locale: "en", + route: ARTICLE_ROUTE, + scopeMode: "global", + section: "articles", + signalDay: expiredSignalDay, + sourcePath: ARTICLE_ROUTE, + title: "Old Dynastic Politics", + updatedAt: NOW - 8 * POPULARITY_DAY_MS, + viewCount: 5, + }); +} + +describe("contents/mutations/popularity", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(NOW)); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("rebuilds finite windows from daily signals and removes expired counters", async () => { + const t = createPopularityConvexTest(); + + await t.mutation(insertPopularityRefreshRows); + + const result = await t.mutation( + internal.contents.mutations.popularity + .refreshLearningPopularityWindowPage, + { + scopeMode: "global", + windowKey: "7d", + } + ); + const counters = await t.query( + async (ctx) => await ctx.db.query("learningPopularityCounters").collect() + ); + + expect(result).toEqual({ + continueCursor: expect.any(String), + isDone: true, + refreshedCounters: 1, + removedCounters: 1, + skipped: false, + }); + expect(counters).toHaveLength(1); + expect(counters[0]).toMatchObject({ + route: SUBJECT_ROUTE, + score: 2, + title: "Current Vector Addition", + }); + }); +}); diff --git a/packages/backend/convex/contents/mutations/popularity.ts b/packages/backend/convex/contents/mutations/popularity.ts new file mode 100644 index 0000000000..1aa9900b60 --- /dev/null +++ b/packages/backend/convex/contents/mutations/popularity.ts @@ -0,0 +1,46 @@ +import { internal } from "@repo/backend/convex/_generated/api"; +import { + type RefreshLearningPopularityWindowPageResult, + refreshLearningPopularityWindowPageArgs, + refreshLearningPopularityWindowPageResultValidator, + type ScheduleLearningPopularityRefreshesResult, + scheduleLearningPopularityRefreshesResultValidator, +} from "@repo/backend/convex/contents/analytics/spec"; +import { + refreshLearningPopularityWindowPage as refreshLearningPopularityWindowPageProgram, + scheduleLearningPopularityRefreshes as scheduleLearningPopularityRefreshesProgram, +} from "@repo/backend/convex/contents/metrics/refresh"; +import { internalMutation } from "@repo/backend/convex/functions"; +import { runConvexProgram } from "@repo/backend/convex/lib/effect"; + +/** Schedules finite popularity-window read-model refresh work. */ +export const scheduleLearningPopularityRefreshes = internalMutation({ + args: {}, + returns: scheduleLearningPopularityRefreshesResultValidator, + handler: async (ctx): Promise => + await runConvexProgram( + scheduleLearningPopularityRefreshesProgram( + ctx, + internal.contents.mutations.popularity + .refreshLearningPopularityWindowPage + ) + ), +}); + +/** Refreshes one bounded page of popularity counters from daily signals. */ +export const refreshLearningPopularityWindowPage = internalMutation({ + args: refreshLearningPopularityWindowPageArgs, + returns: refreshLearningPopularityWindowPageResultValidator, + handler: async ( + ctx, + args + ): Promise => + await runConvexProgram( + refreshLearningPopularityWindowPageProgram( + ctx, + args, + internal.contents.mutations.popularity + .refreshLearningPopularityWindowPage + ) + ), +}); diff --git a/packages/backend/convex/contents/mutations/views.test.ts b/packages/backend/convex/contents/mutations/views.test.ts index 53a869c7b7..980e1d74a6 100644 --- a/packages/backend/convex/contents/mutations/views.test.ts +++ b/packages/backend/convex/contents/mutations/views.test.ts @@ -20,6 +20,10 @@ const SUBJECT_CONTENT_ID = "asset:id:catalog:subject:views"; const EXERCISE_ROUTE = "material/practice/assessment/snbt/quantitative-knowledge/try-out-2026/set-1"; const EXERCISE_CONTENT_ID = "asset:id:catalog:exercise:views"; +const canonicalContext = { + contextKey: "canonical", + contextMode: "canonical", +} as const; /** Builds one route-catalog graph fixture from the route shape under test. */ function getGraphFixture(route: string) { @@ -164,15 +168,66 @@ async function insertExerciseSet(ctx: MutationCtx) { return { contentId: EXERCISE_CONTENT_ID, id }; } +/** Returns the analytics partition for one popularity signal scope. */ +function getSignalPartition(contentId: string) { + return getContentAnalyticsPartition(`${contentId}:global:canonical`); +} + +/** Returns whether one scheduled job is the analytics partition scheduler. */ +function isAnalyticsPartitionJob(job: { args: readonly unknown[] }) { + const [arg] = job.args; + + return typeof arg === "object" && arg !== null && "partition" in arg; +} + +/** Returns whether one scheduled job is a content-view product event. */ +function isContentViewedEventJob(job: { args: readonly unknown[] }) { + const [arg] = job.args; + + if (typeof arg !== "object" || arg === null) { + return false; + } + + return Reflect.get(arg, "event") === "content viewed"; +} + +/** Reads the product analytics user from a scheduled content-view event. */ +function getScheduledDistinctId(job: { args: readonly unknown[] }) { + const [arg] = job.args; + + if (typeof arg !== "object" || arg === null) { + throw new Error("Expected scheduled content-view event arguments."); + } + + const distinctId = Reflect.get(arg, "distinctId"); + + if (typeof distinctId !== "string") { + throw new Error("Expected scheduled content-view event distinct ID."); + } + + return distinctId; +} + /** Reads content-view state that should remain small in each test fixture. */ async function readViewState( t: ReturnType ) { - return await t.query(async (ctx) => ({ - analyticsQueue: await ctx.db.query("contentViewAnalyticsQueue").collect(), - scheduledJobs: await ctx.db.system.query("_scheduled_functions").collect(), - views: await ctx.db.query("contentViews").collect(), - })); + return await t.query(async (ctx) => { + const scheduledFunctions = await ctx.db.system + .query("_scheduled_functions") + .collect(); + + return { + contentViewEvents: scheduledFunctions.filter(isContentViewedEventJob), + engagementQueue: await ctx.db.query("learningEngagementQueue").collect(), + recents: await ctx.db.query("userLearningRecents").collect(), + scheduledJobs: scheduledFunctions.filter(isAnalyticsPartitionJob), + viewerSignals: await ctx.db + .query("learningPopularityViewerSignals") + .collect(), + views: await ctx.db.query("learningViews").collect(), + }; + }); } describe("contents/mutations/views", () => { @@ -207,6 +262,8 @@ describe("contents/mutations/views", () => { { assetId: article.contentId, content_id: article.contentId, + contextKey: "canonical", + contextMode: "canonical", deviceId: "device-1", firstViewedAt: NOW, lastViewedAt: NOW, @@ -215,19 +272,31 @@ describe("contents/mutations/views", () => { section: "articles", }, ]); - expect(state.analyticsQueue).toMatchObject([ + expect(state.engagementQueue).toMatchObject([ { assetId: article.contentId, content_id: article.contentId, + contextKey: "canonical", + contextMode: "canonical", locale: "id", - partition: getContentAnalyticsPartition(article.contentId), + partition: getSignalPartition(article.contentId), route: ARTICLE_ROUTE, section: "articles", + scopeMode: "global", + viewerKey: "device:device-1", viewedAt: NOW, }, ]); + expect(state.viewerSignals).toMatchObject([ + { + ...canonicalContext, + content_id: article.contentId, + scopeMode: "global", + viewerKey: "device:device-1", + }, + ]); expect(state.scheduledJobs.map((job) => job.args[0])).toEqual([ - { partition: getContentAnalyticsPartition(article.contentId) }, + { partition: getSignalPartition(article.contentId) }, ]); }); @@ -263,11 +332,12 @@ describe("contents/mutations/views", () => { firstViewedAt: NOW, lastViewedAt: NOW + 1000, }); - expect(state.analyticsQueue).toHaveLength(1); + expect(state.engagementQueue).toHaveLength(1); expect(state.scheduledJobs).toHaveLength(1); + expect(state.viewerSignals).toHaveLength(1); }); - it("links an existing anonymous device view to a signed-in user", async () => { + it("links an anonymous view to a user without duplicate popularity analytics", async () => { const t = createConvexTestWithBetterAuth(); const identity = await t.mutation(async (ctx) => { const article = await insertArticle(ctx); @@ -309,10 +379,108 @@ describe("contents/mutations/views", () => { lastViewedAt: NOW + 1000, userId: identity.userId, }); - expect(state.analyticsQueue).toHaveLength(1); + expect(state.engagementQueue).toHaveLength(1); + expect(state.recents).toMatchObject([ + { + content_id: identity.contentId, + contextKey: "canonical", + lastViewedAt: NOW + 1000, + userId: identity.userId, + }, + ]); + expect(state.scheduledJobs).toHaveLength(1); + expect(state.viewerSignals).toHaveLength(1); + }); + + it("keeps view ownership separate for different signed-in users on one device", async () => { + const t = createConvexTestWithBetterAuth(); + const identity = await t.mutation(async (ctx) => { + const article = await insertArticle(ctx); + const firstUser = await seedAuthenticatedUser(ctx, { + now: NOW, + suffix: "shared-device-first", + }); + const secondUser = await seedAuthenticatedUser(ctx, { + now: NOW, + suffix: "shared-device-second", + }); + + return { contentId: article.contentId, firstUser, secondUser }; + }); + const firstSignedIn = t.withIdentity({ + sessionId: identity.firstUser.sessionId, + subject: identity.firstUser.authUserId, + }); + const secondSignedIn = t.withIdentity({ + sessionId: identity.secondUser.sessionId, + subject: identity.secondUser.authUserId, + }); + + await firstSignedIn.mutation( + api.contents.mutations.views.recordContentView, + { + contentId: identity.contentId, + deviceId: "shared-device", + locale: "id", + } + ); + + vi.setSystemTime(NOW + 1000); + + const result = await secondSignedIn.mutation( + api.contents.mutations.views.recordContentView, + { + contentId: identity.contentId, + deviceId: "shared-device", + locale: "id", + } + ); + + const state = await readViewState(t); + + expect(result).toEqual({ + alreadyViewed: false, + isNewView: true, + success: true, + }); + expect(state.views).toHaveLength(2); + expect(state.views).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + deviceId: "shared-device", + lastViewedAt: NOW, + userId: identity.firstUser.userId, + }), + expect.objectContaining({ + deviceId: "shared-device", + lastViewedAt: NOW + 1000, + userId: identity.secondUser.userId, + }), + ]) + ); + expect(state.recents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + content_id: identity.contentId, + lastViewedAt: NOW, + userId: identity.firstUser.userId, + }), + expect.objectContaining({ + content_id: identity.contentId, + lastViewedAt: NOW + 1000, + userId: identity.secondUser.userId, + }), + ]) + ); + expect(state.engagementQueue).toHaveLength(2); + expect(state.viewerSignals).toHaveLength(2); + expect(state.contentViewEvents.map(getScheduledDistinctId)).toEqual([ + identity.firstUser.userId, + identity.secondUser.userId, + ]); }); - it("deduplicates signed-in user views across devices", async () => { + it("records signed-in user views per device while deduplicating popularity", async () => { const t = createConvexTestWithBetterAuth(); const identity = await t.mutation(async (ctx) => { const article = await insertArticle(ctx); @@ -347,6 +515,73 @@ describe("contents/mutations/views", () => { const state = await readViewState(t); + expect(result).toEqual({ + alreadyViewed: false, + isNewView: true, + success: true, + }); + expect(state.views).toHaveLength(2); + expect(state.views).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + deviceId: "device-1", + lastViewedAt: NOW, + userId: identity.userId, + }), + expect.objectContaining({ + deviceId: "device-2", + lastViewedAt: NOW + 1000, + userId: identity.userId, + }), + ]) + ); + expect(state.engagementQueue).toHaveLength(1); + expect(state.recents).toMatchObject([ + { + content_id: identity.contentId, + contextKey: "canonical", + lastViewedAt: NOW + 1000, + userId: identity.userId, + }, + ]); + expect(state.viewerSignals).toHaveLength(1); + }); + + it("treats a signed-out same-device repeat as deduped without mutating ownership", async () => { + const t = createConvexTestWithBetterAuth(); + const identity = await t.mutation(async (ctx) => { + const article = await insertArticle(ctx); + const user = await seedAuthenticatedUser(ctx, { + now: NOW, + suffix: "signed-out-repeat", + }); + + return { ...user, contentId: article.contentId }; + }); + const signedIn = t.withIdentity({ + sessionId: identity.sessionId, + subject: identity.authUserId, + }); + + await signedIn.mutation(api.contents.mutations.views.recordContentView, { + contentId: identity.contentId, + deviceId: "device-1", + locale: "id", + }); + + vi.setSystemTime(NOW + 1000); + + const result = await t.mutation( + api.contents.mutations.views.recordContentView, + { + contentId: identity.contentId, + deviceId: "device-1", + locale: "id", + } + ); + + const state = await readViewState(t); + expect(result).toEqual({ alreadyViewed: true, isNewView: false, @@ -354,11 +589,90 @@ describe("contents/mutations/views", () => { }); expect(state.views).toHaveLength(1); expect(state.views[0]).toMatchObject({ - deviceId: "device-1", - lastViewedAt: NOW + 1000, + lastViewedAt: NOW, userId: identity.userId, }); - expect(state.analyticsQueue).toHaveLength(1); + expect(state.engagementQueue).toHaveLength(1); + expect(state.scheduledJobs).toHaveLength(1); + expect(state.viewerSignals).toHaveLength(1); + expect(state.contentViewEvents).toHaveLength(1); + }); + + it("does not add another same-day popularity signal after cross-device sign-out", async () => { + const t = createConvexTestWithBetterAuth(); + const identity = await t.mutation(async (ctx) => { + const article = await insertArticle(ctx); + const user = await seedAuthenticatedUser(ctx, { + now: NOW, + suffix: "cross-device-sign-out", + }); + + return { ...user, contentId: article.contentId }; + }); + const signedIn = t.withIdentity({ + sessionId: identity.sessionId, + subject: identity.authUserId, + }); + + await signedIn.mutation(api.contents.mutations.views.recordContentView, { + contentId: identity.contentId, + deviceId: "device-1", + locale: "id", + }); + + vi.setSystemTime(NOW + 1000); + + await signedIn.mutation(api.contents.mutations.views.recordContentView, { + contentId: identity.contentId, + deviceId: "device-2", + locale: "id", + }); + + vi.setSystemTime(NOW + 2000); + + const result = await t.mutation( + api.contents.mutations.views.recordContentView, + { + contentId: identity.contentId, + deviceId: "device-2", + locale: "id", + } + ); + + const state = await readViewState(t); + + expect(result).toEqual({ + alreadyViewed: true, + isNewView: false, + success: true, + }); + expect(state.views).toHaveLength(2); + expect(state.views).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + deviceId: "device-1", + lastViewedAt: NOW, + userId: identity.userId, + }), + expect.objectContaining({ + deviceId: "device-2", + lastViewedAt: NOW + 1000, + userId: identity.userId, + }), + ]) + ); + expect(state.engagementQueue).toHaveLength(1); + expect(state.recents).toMatchObject([ + { + content_id: identity.contentId, + contextKey: "canonical", + lastViewedAt: NOW + 1000, + userId: identity.userId, + }, + ]); + expect(state.scheduledJobs).toHaveLength(1); + expect(state.viewerSignals).toHaveLength(1); + expect(state.contentViewEvents).toHaveLength(2); }); it("returns a best-effort miss when the graph content ID has no route row", async () => { @@ -381,8 +695,9 @@ describe("contents/mutations/views", () => { success: false, }); expect(state.views).toEqual([]); - expect(state.analyticsQueue).toEqual([]); + expect(state.engagementQueue).toEqual([]); expect(state.scheduledJobs).toEqual([]); + expect(state.viewerSignals).toEqual([]); }); it("returns best-effort misses for route kinds without tracked source rows", async () => { @@ -436,8 +751,9 @@ describe("contents/mutations/views", () => { success: false, }); expect(state.views).toEqual([]); - expect(state.analyticsQueue).toEqual([]); + expect(state.engagementQueue).toEqual([]); expect(state.scheduledJobs).toEqual([]); + expect(state.viewerSignals).toEqual([]); }); it("returns a best-effort miss for mismatched route catalog graph IDs", async () => { @@ -477,8 +793,9 @@ describe("contents/mutations/views", () => { success: false, }); expect(state.views).toEqual([]); - expect(state.analyticsQueue).toEqual([]); + expect(state.engagementQueue).toEqual([]); expect(state.scheduledJobs).toEqual([]); + expect(state.viewerSignals).toEqual([]); }); it("reports auth component IO failures through the typed boundary", async () => { @@ -529,6 +846,7 @@ describe("contents/mutations/views", () => { fixtures.exercise.contentId, ]) ); - expect(state.analyticsQueue).toHaveLength(2); + expect(state.engagementQueue).toHaveLength(2); + expect(state.viewerSignals).toHaveLength(2); }); }); diff --git a/packages/backend/convex/contents/mutations/views.ts b/packages/backend/convex/contents/mutations/views.ts index cf3c89cced..2496c2bde0 100644 --- a/packages/backend/convex/contents/mutations/views.ts +++ b/packages/backend/convex/contents/mutations/views.ts @@ -1,8 +1,5 @@ import { internal } from "@repo/backend/convex/_generated/api"; -import { - type ContentViewSchedulerTargets, - recordUniqueContentView, -} from "@repo/backend/convex/contents/views/impl"; +import { recordUniqueContentView } from "@repo/backend/convex/contents/views/impl"; import { type RecordContentViewResult, recordContentViewArgs, @@ -11,11 +8,6 @@ import { import { mutation } from "@repo/backend/convex/functions"; import { runConvexProgram } from "@repo/backend/convex/lib/effect"; -const schedulerTargets: ContentViewSchedulerTargets = { - scheduleAnalyticsPartition: - internal.contents.mutations.analytics.scheduleContentAnalyticsPartition, -}; - /** * Records a unique content view per user or device. * @@ -28,6 +20,10 @@ export const recordContentView = mutation({ returns: recordContentViewResultValidator, handler: async (ctx, args): Promise => await runConvexProgram( - recordUniqueContentView(ctx, args, schedulerTargets) + recordUniqueContentView( + ctx, + args, + internal.contents.mutations.analytics.scheduleContentAnalyticsPartition + ) ), }); diff --git a/packages/backend/convex/contents/popularity.ts b/packages/backend/convex/contents/popularity.ts new file mode 100644 index 0000000000..f44a371bee --- /dev/null +++ b/packages/backend/convex/contents/popularity.ts @@ -0,0 +1,112 @@ +export const learningPopularityWindowValues = [ + "1d", + "7d", + "14d", + "30d", + "90d", + "180d", + "365d", + "lifetime", +] as const; + +export type LearningPopularityWindow = + (typeof learningPopularityWindowValues)[number]; + +export const learningPopularityScopeValues = ["global", "placement"] as const; + +export type LearningPopularityScope = + (typeof learningPopularityScopeValues)[number]; + +const popularityWindowDayCounts: { + readonly [windowKey in Exclude]: number; +} = { + "1d": 1, + "7d": 7, + "14d": 14, + "30d": 30, + "90d": 90, + "180d": 180, + "365d": 365, +}; + +/** Milliseconds in one UTC popularity signal day. */ +export const POPULARITY_DAY_MS = 24 * 60 * 60 * 1000; + +/** Returns the UTC day bucket that owns one engagement timestamp. */ +export function getPopularitySignalDay(timestamp: number) { + return Math.floor(timestamp / POPULARITY_DAY_MS) * POPULARITY_DAY_MS; +} + +/** Returns the stable popularity identity for one viewer event. */ +export function createPopularityViewerKey(input: { + readonly deviceId: string; + readonly userId?: string; +}) { + if (input.userId) { + return `user:${input.userId}`; + } + + return `device:${input.deviceId}`; +} + +/** Returns the default popularity window used by homepage ranked reads. */ +export function getDefaultPopularityWindow(): LearningPopularityWindow { + return "7d"; +} + +/** Returns the all-time popularity window used by background generation jobs. */ +export function getLifetimePopularityWindow(): LearningPopularityWindow { + return "lifetime"; +} + +/** + * Narrows popularity windows to the finite windows that must expire old daily + * signals through scheduled read-model refreshes. + */ +export function isFinitePopularityWindow( + windowKey: LearningPopularityWindow +): windowKey is Exclude { + return windowKey !== "lifetime"; +} + +/** Returns the finite popularity windows maintained from daily signal rows. */ +export function getFinitePopularityWindows() { + return learningPopularityWindowValues.filter(isFinitePopularityWindow); +} + +/** Returns how many UTC signal days belong to one finite popularity window. */ +export function getPopularityWindowDayCount( + windowKey: Exclude +) { + return popularityWindowDayCounts[windowKey]; +} + +/** + * Returns the first UTC signal day included by a finite popularity window at a + * given refresh time. + */ +export function getPopularityWindowStartDay( + windowKey: Exclude, + timestamp: number +) { + const currentDay = getPopularitySignalDay(timestamp); + const dayCount = getPopularityWindowDayCount(windowKey); + + return currentDay - (dayCount - 1) * POPULARITY_DAY_MS; +} + +/** Returns whether a daily signal belongs to a finite window at refresh time. */ +export function isPopularitySignalInWindow({ + signalDay, + timestamp, + windowKey, +}: { + readonly signalDay: number; + readonly timestamp: number; + readonly windowKey: Exclude; +}) { + const currentDay = getPopularitySignalDay(timestamp); + const startDay = getPopularityWindowStartDay(windowKey, timestamp); + + return signalDay >= startDay && signalDay <= currentDay; +} diff --git a/packages/backend/convex/contents/queries/audio.test.ts b/packages/backend/convex/contents/queries/audio.test.ts index edc7e207fc..68454fe3ac 100644 --- a/packages/backend/convex/contents/queries/audio.test.ts +++ b/packages/backend/convex/contents/queries/audio.test.ts @@ -5,6 +5,7 @@ import { MAX_AUDIO_QUEUE_POPULAR_ITEMS_PER_TYPE, MIN_VIEW_THRESHOLD, } from "@repo/backend/convex/audioStudies/constants"; +import { getLifetimePopularityWindow } from "@repo/backend/convex/contents/popularity"; import type { Locale } from "@repo/backend/convex/lib/validators/contents"; import schema from "@repo/backend/convex/schema"; import { getTestAudioContent } from "@repo/backend/convex/test.helpers"; @@ -26,9 +27,14 @@ const audioRouteKinds = [ "article", "curriculum-lesson", ] as const satisfies readonly Doc<"contentRoutes">["kind"][]; +const canonicalContext = { + contextKey: "canonical", + contextMode: "canonical", +} as const; type AudioRouteKind = (typeof audioRouteKinds)[number]; +/** Builds the graph asset identity used by audio source and popularity fixtures. */ function getGraph(locale: Locale, route: string) { const graph = createLearningGraphIdentityFromRoute({ locale, route }); @@ -42,6 +48,7 @@ function getGraph(locale: Locale, route: string) { }; } +/** Inserts the route-catalog row needed by audio source lookup hydration. */ async function insertContentRoute( ctx: MutationCtx, input: { @@ -70,6 +77,33 @@ async function insertContentRoute( return graph; } +/** Inserts one global lifetime popularity counter for audio candidate ranking. */ +async function insertPopularityCounter( + ctx: MutationCtx, + input: { + readonly graph: ReturnType; + readonly locale: Locale; + readonly route: string; + readonly score: number; + readonly section: Doc<"learningPopularityCounters">["section"]; + readonly title: string; + } +) { + await ctx.db.insert("learningPopularityCounters", { + ...input.graph, + ...canonicalContext, + locale: input.locale, + route: input.route, + score: input.score, + section: input.section, + scopeMode: "global", + sourcePath: input.route, + title: input.title, + updatedAt: 1, + windowKey: getLifetimePopularityWindow(), + }); +} + describe("contents/queries/audio", () => { it("returns one ranked item per slug with source lookup metadata", async () => { const t = convexTest(schema, convexModules); @@ -186,26 +220,29 @@ describe("contents/queries/audio", () => { title: "Penjumlahan Vektor", }); - await ctx.db.insert("learningPopularity", { - ...articleGraph, + await insertPopularityCounter(ctx, { + graph: articleGraph, locale: "en", + route: REAL_DYNASTIC_ARTICLE_SLUG, + score: 80, section: "articles", - updatedAt: 1, - viewCount: 80, + title: "Dynastic Politics", }); - await ctx.db.insert("learningPopularity", { - ...englishSubjectGraph, + await insertPopularityCounter(ctx, { + graph: englishSubjectGraph, locale: "en", + route: REAL_VECTOR_SECTION_SLUG, + score: 40, section: "material", - updatedAt: 1, - viewCount: 40, + title: "Vector Addition", }); - await ctx.db.insert("learningPopularity", { - ...indonesianSubjectGraph, + await insertPopularityCounter(ctx, { + graph: indonesianSubjectGraph, locale: "id", + route: REAL_VECTOR_SECTION_SLUG, + score: 25, section: "material", - updatedAt: 1, - viewCount: 25, + title: "Penjumlahan Vektor", }); }); @@ -258,12 +295,13 @@ describe("contents/queries/audio", () => { }); const articleGraph = getGraph("en", REAL_DYNASTIC_ARTICLE_SLUG); - await ctx.db.insert("learningPopularity", { - ...articleGraph, + await insertPopularityCounter(ctx, { + graph: articleGraph, locale: "en", + route: REAL_DYNASTIC_ARTICLE_SLUG, + score: 80, section: "articles", - updatedAt: 1, - viewCount: 80, + title: "Dynastic Politics", }); await ctx.db.insert("curriculumLessons", { topicId: await ctx.db.insert("curriculumTopics", { @@ -292,12 +330,13 @@ describe("contents/queries/audio", () => { }); const subjectGraph = getGraph("en", REAL_VECTOR_SECTION_SLUG); - await ctx.db.insert("learningPopularity", { - ...subjectGraph, + await insertPopularityCounter(ctx, { + graph: subjectGraph, locale: "en", + route: REAL_VECTOR_SECTION_SLUG, + score: 40, section: "material", - updatedAt: 1, - viewCount: 40, + title: "Vector Addition", }); }); @@ -309,6 +348,79 @@ describe("contents/queries/audio", () => { expect(result).toEqual([]); }); + it("pages past non-audio popularity rows to find queue candidates", async () => { + const t = convexTest(schema, convexModules); + + await t.mutation(async (ctx) => { + for ( + let index = 0; + index < MAX_AUDIO_QUEUE_POPULAR_ITEMS_PER_TYPE; + index++ + ) { + const slug = `articles/politics/non-audio-candidate-${index}`; + const graph = await insertContentRoute(ctx, { + kind: "article", + locale: "en", + route: slug, + title: `Non Audio Candidate ${index}`, + }); + + await insertPopularityCounter(ctx, { + graph, + locale: "en", + route: slug, + score: 1000 - index, + section: "articles", + title: `Non Audio Candidate ${index}`, + }); + } + + const audioSlug = "articles/politics/audio-ready-after-page"; + const audioGraph = await insertContentRoute(ctx, { + kind: "article", + locale: "en", + route: audioSlug, + title: "Audio Ready After Page", + }); + + await ctx.db.insert("audioContentSources", { + ...getTestAudioContent({ + contentHash: "source-audio-ready-after-page", + locale: "en", + route: audioSlug, + }), + syncedAt: 2, + }); + await insertPopularityCounter(ctx, { + graph: audioGraph, + locale: "en", + route: audioSlug, + score: 100, + section: "articles", + title: "Audio Ready After Page", + }); + }); + + const result = await t.query( + internal.contents.queries.audio.getPopularContentForAudioQueue, + {} + ); + + expect(result).toEqual([ + { + sourceContent: expect.objectContaining({ + contentHash: "source-audio-ready-after-page", + content_id: getGraph("en", "articles/politics/audio-ready-after-page") + .content_id, + contentType: "article", + locale: "en", + route: "articles/politics/audio-ready-after-page", + }), + viewCount: 100, + }, + ]); + }); + it("ignores popularity rows below the queue threshold before source lookup", async () => { const t = convexTest(schema, convexModules); @@ -336,12 +448,13 @@ describe("contents/queries/audio", () => { }), syncedAt: 2, }); - await ctx.db.insert("learningPopularity", { - ...getGraph("en", REAL_DYNASTIC_ARTICLE_SLUG), + await insertPopularityCounter(ctx, { + graph: getGraph("en", REAL_DYNASTIC_ARTICLE_SLUG), locale: "en", + route: REAL_DYNASTIC_ARTICLE_SLUG, + score: MIN_VIEW_THRESHOLD - 1, section: "articles", - updatedAt: 1, - viewCount: MIN_VIEW_THRESHOLD - 1, + title: "Dynastic Politics", }); }); @@ -390,12 +503,13 @@ describe("contents/queries/audio", () => { route: slug, title: `Audio Candidate ${index}`, }); - await ctx.db.insert("learningPopularity", { - ...graph, + await insertPopularityCounter(ctx, { + graph, locale: "en", + route: slug, + score: 1000 - index, section: "articles", - updatedAt: 1, - viewCount: 1000 - index, + title: `Audio Candidate ${index}`, }); } }); diff --git a/packages/backend/convex/contents/queries/audio.ts b/packages/backend/convex/contents/queries/audio.ts index e09f4d5c66..cac90c8744 100644 --- a/packages/backend/convex/contents/queries/audio.ts +++ b/packages/backend/convex/contents/queries/audio.ts @@ -1,91 +1,178 @@ -import { internalQuery } from "@repo/backend/convex/_generated/server"; +import type { Doc } from "@repo/backend/convex/_generated/dataModel"; +import { + internalQuery, + type QueryCtx, +} from "@repo/backend/convex/_generated/server"; import { MAX_AUDIO_QUEUE_POPULAR_ITEMS_PER_TYPE, MIN_VIEW_THRESHOLD, } from "@repo/backend/convex/audioStudies/constants"; -import { getAudioContentSourceByRoute } from "@repo/backend/convex/audioStudies/helpers/sources"; +import { getAudioContentSourceByContentId } from "@repo/backend/convex/audioStudies/helpers/sources"; import { mergePopularAudioContentItems } from "@repo/backend/convex/contents/helpers/popularity"; +import { getLifetimePopularityWindow } from "@repo/backend/convex/contents/popularity"; import { type PopularAudioContentItem, popularAudioContentItemValidator, } from "@repo/backend/convex/contents/validators"; +import { + getUnknownErrorMessage, + runConvexProgram, +} from "@repo/backend/convex/lib/effect"; +import { + type Locale, + SUPPORTED_CONTENT_LOCALES, +} from "@repo/backend/convex/lib/validators/contents"; import { v } from "convex/values"; +import { Effect, Schema } from "effect"; -/** - * Returns the current top article and subject candidates for audio generation. - * - * This query is used by an internal action so popularity reads never happen - * inside the mutation that writes audio queue rows. - */ -export const getPopularContentForAudioQueue = internalQuery({ - args: {}, - returns: v.array(popularAudioContentItemValidator), - handler: async (ctx) => { - const [articleRows, subjectRows] = await Promise.all([ - ctx.db - .query("learningPopularity") - .withIndex("by_section_and_viewCount_and_content_id", (q) => - q.eq("section", "articles").gte("viewCount", MIN_VIEW_THRESHOLD) - ) - .order("desc") - .take(MAX_AUDIO_QUEUE_POPULAR_ITEMS_PER_TYPE), - ctx.db - .query("learningPopularity") - .withIndex("by_section_and_viewCount_and_content_id", (q) => - q.eq("section", "material").gte("viewCount", MIN_VIEW_THRESHOLD) - ) - .order("desc") - .take(MAX_AUDIO_QUEUE_POPULAR_ITEMS_PER_TYPE), - ]); - const sourceItems: PopularAudioContentItem[] = []; - - for (const row of articleRows) { - const route = await ctx.db - .query("contentRoutes") - .withIndex("by_content_id", (q) => q.eq("content_id", row.content_id)) - .unique(); - - if (route?.kind !== "article" || route.content_id !== route.assetId) { - continue; - } +type PopularityCounterRow = Doc<"learningPopularityCounters">; - const sourceContent = await getAudioContentSourceByRoute(ctx, route); +const popularAudioIoFailedCode = "POPULAR_AUDIO_IO_FAILED"; +const popularAudioCandidatePageCount = 3; +const maxPopularAudioSourceLookupsPerType = + MAX_AUDIO_QUEUE_POPULAR_ITEMS_PER_TYPE * popularAudioCandidatePageCount; - if (!sourceContent) { - continue; - } +/** Raised when the audio queue candidate query cannot read its ranked models. */ +class PopularAudioIoError extends Schema.TaggedError()( + "PopularAudioIoError", + { + code: Schema.Literal(popularAudioIoFailedCode), + message: Schema.String, + } +) {} + +/** Maps thrown Convex IO failures into the popular-audio error channel. */ +function toPopularAudioIoError(error: unknown) { + return new PopularAudioIoError({ + code: popularAudioIoFailedCode, + message: getUnknownErrorMessage(error), + }); +} + +/** Reads bounded ranked popularity pages for one content section and locale. */ +const loadPopularRowsForLocale = Effect.fn( + "contents.audio.loadPopularRowsForLocale" +)(function* ( + ctx: QueryCtx, + section: PopularityCounterRow["section"], + locale: Locale +) { + const windowKey = getLifetimePopularityWindow(); + const rows: PopularityCounterRow[] = []; + let cursor: string | null = null; + let pagesRead = 0; + + while (pagesRead < popularAudioCandidatePageCount) { + const page = yield* Effect.tryPromise({ + try: () => + ctx.db + .query("learningPopularityCounters") + .withIndex("by_section_locale_scope_window_score_id", (q) => + q + .eq("section", section) + .eq("locale", locale) + .eq("scopeMode", "global") + .eq("windowKey", windowKey) + ) + .order("desc") + .paginate({ + cursor, + numItems: MAX_AUDIO_QUEUE_POPULAR_ITEMS_PER_TYPE, + }), + catch: toPopularAudioIoError, + }); - sourceItems.push({ - sourceContent, - viewCount: row.viewCount, - }); + rows.push(...page.page.filter((row) => row.score >= MIN_VIEW_THRESHOLD)); + pagesRead += 1; + + if (page.isDone) { + break; } - for (const row of subjectRows) { - const route = await ctx.db - .query("contentRoutes") - .withIndex("by_content_id", (q) => q.eq("content_id", row.content_id)) - .unique(); - - if ( - route?.kind !== "curriculum-lesson" || - route.content_id !== route.assetId - ) { - continue; - } + cursor = page.continueCursor; + } - const sourceContent = await getAudioContentSourceByRoute(ctx, route); + return rows; +}); - if (!sourceContent) { - continue; +/** Reads ranked popularity rows for one content section across supported locales. */ +const loadPopularRows = Effect.fn("contents.audio.loadPopularRows")(function* ( + ctx: QueryCtx, + section: PopularityCounterRow["section"] +) { + const pages = yield* Effect.forEach(SUPPORTED_CONTENT_LOCALES, (locale) => + loadPopularRowsForLocale(ctx, section, locale) + ); + + return pages + .flat() + .sort((left, right) => right.score - left.score) + .slice(0, maxPopularAudioSourceLookupsPerType); +}); + +/** Resolves one ranked popularity row to an audio source candidate. */ +const loadAudioItem = Effect.fn("contents.audio.loadAudioItem")(function* ( + ctx: QueryCtx, + row: PopularityCounterRow +) { + const sourceContent = yield* Effect.tryPromise({ + try: () => getAudioContentSourceByContentId(ctx, row.content_id), + catch: toPopularAudioIoError, + }); + + if (!sourceContent) { + return null; + } + + return { + sourceContent, + viewCount: row.score, + }; +}); + +/** Resolves enough audio-ready items from bounded ranked popularity candidates. */ +const loadPopularAudioItems = Effect.fn("contents.audio.loadPopularAudioItems")( + function* (ctx: QueryCtx, section: PopularityCounterRow["section"]) { + const rows = yield* loadPopularRows(ctx, section); + const items: PopularAudioContentItem[] = []; + + for (const row of rows) { + const item = yield* loadAudioItem(ctx, row); + + if (item) { + items.push(item); } - sourceItems.push({ - sourceContent, - viewCount: row.viewCount, - }); + if (items.length >= MAX_AUDIO_QUEUE_POPULAR_ITEMS_PER_TYPE) { + break; + } } - return mergePopularAudioContentItems(sourceItems); - }, + return items; + } +); + +/** Reads current popular audio queue candidates from ranked learning counters. */ +const listPopularAudioContent = Effect.fn( + "contents.audio.listPopularAudioContent" +)(function* (ctx: QueryCtx) { + const [articleItems, subjectItems] = yield* Effect.all([ + loadPopularAudioItems(ctx, "articles"), + loadPopularAudioItems(ctx, "material"), + ]); + + return mergePopularAudioContentItems([...articleItems, ...subjectItems]); +}); + +/** + * Returns the current top article and subject candidates for audio generation. + * + * This query is used by an internal action so popularity reads never happen + * inside the mutation that writes audio queue rows. + */ +export const getPopularContentForAudioQueue = internalQuery({ + args: {}, + returns: v.array(popularAudioContentItemValidator), + handler: async (ctx): Promise => + await runConvexProgram(listPopularAudioContent(ctx)), }); diff --git a/packages/backend/convex/contents/queries/recent.test.ts b/packages/backend/convex/contents/queries/recent.test.ts index 7ff6aa51dd..e2ae006f1a 100644 --- a/packages/backend/convex/contents/queries/recent.test.ts +++ b/packages/backend/convex/contents/queries/recent.test.ts @@ -1,5 +1,5 @@ import { api } from "@repo/backend/convex/_generated/api"; -import type { Id } from "@repo/backend/convex/_generated/dataModel"; +import type { Doc, Id } from "@repo/backend/convex/_generated/dataModel"; import type { MutationCtx } from "@repo/backend/convex/_generated/server"; import { createConvexTestWithBetterAuth, @@ -9,6 +9,10 @@ import { createLearningGraphIdentityFromRoute } from "@repo/contents/_types/lear import { describe, expect, it } from "vitest"; const NOW = Date.parse("2026-01-01T00:00:00.000Z"); +const canonicalContext = { + contextKey: "canonical", + contextMode: "canonical", +} as const; describe("contents/queries/recent", () => { it("returns recently viewed materials through graph route projections", async () => { @@ -21,9 +25,10 @@ describe("contents/queries/recent", () => { const lessonId = await insertCurriculumLesson(ctx, "viewed"); const ref = await insertMaterialRoute(ctx, "viewed"); - await insertMaterialView(ctx, { + await insertMaterialRecent(ctx, { ref, lastViewedAt: NOW, + materialDomain: "mathematics", suffix: "viewed", userId: identity.userId, }); @@ -57,20 +62,19 @@ describe("contents/queries/recent", () => { expect(seeded.lessonId).not.toBe(seeded.ref.assetId); }); - it("drops recently viewed materials without graph route projections", async () => { + it("drops recently viewed materials without material-domain projection", async () => { const t = createConvexTestWithBetterAuth(); const identity = await t.mutation(async (ctx) => { const identity = await seedAuthenticatedUser(ctx, { now: NOW, - suffix: "recent-missing-route", + suffix: "recent-missing-domain", }); - await insertCurriculumLesson(ctx, "missing-route"); - const ref = getMaterialGraph("missing-route"); + const ref = getMaterialGraph("missing-domain"); - await insertMaterialView(ctx, { + await insertMaterialRecent(ctx, { ref, lastViewedAt: NOW, - suffix: "missing-route", + suffix: "missing-domain", userId: identity.userId, }); @@ -89,6 +93,149 @@ describe("contents/queries/recent", () => { expect(results).toEqual([]); }); + + it("uses the current route projection when a recent row has stale route copy", async () => { + const t = createConvexTestWithBetterAuth(); + const seeded = await t.mutation(async (ctx) => { + const identity = await seedAuthenticatedUser(ctx, { + now: NOW, + suffix: "recent-stale-route", + }); + const ref = await insertMaterialRoute(ctx, "stale-route"); + + await insertMaterialRecent(ctx, { + ref, + lastViewedAt: NOW, + materialDomain: "mathematics", + suffix: "stale-route", + userId: identity.userId, + }); + + const route = await ctx.db + .query("contentRoutes") + .withIndex("by_content_id", (q) => q.eq("content_id", ref.assetId)) + .unique(); + + if (!route) { + expect.fail("Expected content route for stale recent fixture."); + } + + await ctx.db.patch(route._id, { + route: "subjects/mathematics/topic-current/section-current", + title: "Material current", + }); + + return { ...identity, ref }; + }); + + const results = await t + .withIdentity({ + sessionId: seeded.sessionId, + subject: seeded.authUserId, + }) + .query(api.contents.queries.recent.getRecentlyViewed, { + locale: "en", + limit: 5, + }); + + expect(results).toEqual([ + expect.objectContaining({ + href: "/subjects/mathematics/topic-current/section-current", + route: "subjects/mathematics/topic-current/section-current", + title: "Material current", + }), + ]); + }); + + it("drops practice recents from material Continue Learning cards", async () => { + const t = createConvexTestWithBetterAuth(); + const identity = await t.mutation(async (ctx) => { + const identity = await seedAuthenticatedUser(ctx, { + now: NOW, + suffix: "recent-practice", + }); + const ref = await insertPracticeRoute(ctx, "recent-practice"); + + await insertMaterialRecent(ctx, { + ref, + lastViewedAt: NOW, + materialDomain: "mathematics", + suffix: "recent-practice", + userId: identity.userId, + }); + + return identity; + }); + + const results = await t + .withIdentity({ + sessionId: identity.sessionId, + subject: identity.authUserId, + }) + .query(api.contents.queries.recent.getRecentlyViewed, { + locale: "en", + limit: 5, + }); + + expect(results).toEqual([]); + }); + + it("pages past filtered practice recents to fill Continue Learning cards", async () => { + const t = createConvexTestWithBetterAuth(); + const seeded = await t.mutation(async (ctx) => { + const identity = await seedAuthenticatedUser(ctx, { + now: NOW, + suffix: "recent-page-past-practice", + }); + const practiceRef = await insertPracticeRoute( + ctx, + "recent-page-practice" + ); + const materialRef = await insertMaterialRoute( + ctx, + "recent-page-material" + ); + + for (let index = 0; index < 20; index++) { + await insertMaterialRecent(ctx, { + ref: practiceRef, + lastViewedAt: NOW + 100 - index, + materialDomain: "mathematics", + suffix: `recent-page-practice-${index}`, + userId: identity.userId, + }); + } + + await insertMaterialRecent(ctx, { + ref: materialRef, + lastViewedAt: NOW, + materialDomain: "mathematics", + suffix: "recent-page-material", + userId: identity.userId, + }); + + return { ...identity, materialRef }; + }); + + const results = await t + .withIdentity({ + sessionId: seeded.sessionId, + subject: seeded.authUserId, + }) + .query(api.contents.queries.recent.getRecentlyViewed, { + locale: "en", + limit: 1, + }); + + expect(results).toEqual([ + expect.objectContaining({ + assetId: seeded.materialRef.assetId, + route: + "subjects/mathematics/topic-recent-page-material/section-recent-page-material", + title: "Material recent-page-material", + }), + ]); + }); }); /** Inserts one curriculum lesson row for recently viewed query tests. */ @@ -149,30 +296,68 @@ async function insertMaterialRoute(ctx: MutationCtx, suffix: string) { return identity; } -/** Inserts one material content-view row for the signed-in user. */ -async function insertMaterialView( +/** Inserts the route catalog graph projection for one practice set. */ +async function insertPracticeRoute(ctx: MutationCtx, suffix: string) { + const route = getPracticeSetRoute(suffix); + const identity = createLearningGraphIdentityFromRoute({ + locale: "en", + route, + }); + + if (!identity) { + expect.fail(`Expected practice graph identity for ${route}.`); + } + + await ctx.db.insert("contentRoutes", { + ...identity, + authors: [{ name: "Nakafa Author" }], + contentHash: `practice-hash-${suffix}`, + content_id: identity.assetId, + date: NOW, + description: `Practice ${suffix}`, + kind: "exercise-set", + locale: "en", + markdown: true, + materialDomain: "mathematics", + route: getPublicPracticeSetRoute(suffix), + section: "material", + sourcePath: route, + syncedAt: NOW, + title: `Practice ${suffix}`, + }); + + return identity; +} + +/** Inserts one signed-in Continue Learning read-model row. */ +async function insertMaterialRecent( ctx: MutationCtx, { ref, lastViewedAt, + materialDomain, suffix, userId, }: { ref: ReturnType; lastViewedAt: number; + materialDomain?: Doc<"userLearningRecents">["materialDomain"]; suffix: string; userId: Id<"users">; } ) { - await ctx.db.insert("contentViews", { + await ctx.db.insert("userLearningRecents", { ...ref, + ...canonicalContext, content_id: ref.assetId, - deviceId: "device-recent", - firstViewedAt: NOW, + description: `Description ${suffix}`, lastViewedAt, locale: "en", - route: getMaterialLessonRoute(suffix), + ...(materialDomain ? { materialDomain } : {}), + route: getPublicMaterialLessonRoute(suffix), section: "material", + sourcePath: getMaterialLessonRoute(suffix), + title: `Material ${suffix}`, userId, }); } @@ -201,3 +386,13 @@ function getMaterialLessonRoute(suffix: string) { function getPublicMaterialLessonRoute(suffix: string) { return `subjects/mathematics/topic-${suffix}/section-${suffix}`; } + +/** Builds the canonical practice route used by stale recent fixtures. */ +function getPracticeSetRoute(suffix: string) { + return `material/practice/assessment/snbt/quantitative-knowledge/${suffix}/set-1`; +} + +/** Builds the public practice route used by stale recent fixtures. */ +function getPublicPracticeSetRoute(suffix: string) { + return `practice/assessment/snbt/quantitative-knowledge/${suffix}/set-1`; +} diff --git a/packages/backend/convex/contents/queries/recent.ts b/packages/backend/convex/contents/queries/recent.ts index 37b5e9b0f6..1ea577e8f1 100644 --- a/packages/backend/convex/contents/queries/recent.ts +++ b/packages/backend/convex/contents/queries/recent.ts @@ -1,70 +1,127 @@ +import type { Doc } from "@repo/backend/convex/_generated/dataModel"; +import type { QueryCtx } from "@repo/backend/convex/_generated/server"; import { query } from "@repo/backend/convex/_generated/server"; +import { toLearningContextQuery } from "@repo/backend/convex/contents/context"; import { buildContentSearchRef } from "@repo/backend/convex/contents/helpers/search/documents"; +import { + getUnknownErrorMessage, + runConvexProgram, +} from "@repo/backend/convex/lib/effect"; import { getOptionalAppUser } from "@repo/backend/convex/lib/helpers/auth"; import { localeValidator } from "@repo/backend/convex/lib/validators/contents"; import { recentlyViewedSubjectValidator } from "@repo/backend/convex/lib/validators/trending"; import { vv } from "@repo/backend/convex/lib/validators/vv"; -import type { Infer } from "convex/values"; +import { cleanSlug } from "@repo/utilities/helper"; +import { type Infer, v } from "convex/values"; +import { Effect, Schema } from "effect"; type RecentlyViewedSubject = Infer; -/** Returns the current user's recently viewed subjects for one locale. */ -export const getRecentlyViewed = query({ - args: { - locale: localeValidator, - limit: vv.optional(vv.number()), - }, - returns: vv.array(recentlyViewedSubjectValidator), - handler: async (ctx, args) => { - const limit = args.limit ?? 5; - const user = await getOptionalAppUser(ctx); +const defaultRecentLearningLimit = 5; +const maxRecentLearningLimit = 20; +const recentLearningPageSize = 20; +const recentLearningMaxPages = 5; - if (!user) { - return []; - } +/** Convex validator for bounded Continue Learning query inputs. */ +const getRecentlyViewedArgs = { + locale: localeValidator, + limit: vv.optional(vv.number()), +}; + +/** Validator-owned argument contract used by the internal query program. */ +const getRecentlyViewedArgsValidator = v.object(getRecentlyViewedArgs); + +type ListRecentLearningArgs = Infer; + +const recentLearningIoFailedCode = "RECENT_LEARNING_IO_FAILED"; - const recentViews = await ctx.db - .query("contentViews") - .withIndex("by_userId_and_section_and_locale_and_lastViewedAt", (q) => - q - .eq("userId", user.appUser._id) - .eq("section", "material") - .eq("locale", args.locale) - ) - .order("desc") - .take(limit); - - if (recentViews.length === 0) { +/** Raised when Continue Learning cannot read its ranked model. */ +class RecentLearningIoError extends Schema.TaggedError()( + "RecentLearningIoError", + { + code: Schema.Literal(recentLearningIoFailedCode), + message: Schema.String, + } +) {} + +/** Maps thrown Convex IO failures into the Continue Learning error channel. */ +function toRecentLearningIoError(error: unknown) { + return new RecentLearningIoError({ + code: recentLearningIoFailedCode, + message: getUnknownErrorMessage(error), + }); +} + +/** Reads the authenticated learner's ranked Continue Learning rows. */ +const listRecentLearning = Effect.fn("contents.recent.listRecentLearning")( + function* (ctx: QueryCtx, args: ListRecentLearningArgs) { + const rawLimit = args.limit ?? defaultRecentLearningLimit; + const limit = Math.min(Math.max(rawLimit, 0), maxRecentLearningLimit); + const user = yield* Effect.tryPromise({ + try: () => getOptionalAppUser(ctx), + catch: toRecentLearningIoError, + }); + + if (!(user && limit > 0)) { return []; } - const results: RecentlyViewedSubject[] = []; + const subjects: RecentlyViewedSubject[] = []; + let cursor: string | null = null; + let pagesRead = 0; - for (const view of recentViews) { - const route = await ctx.db - .query("contentRoutes") - .withIndex("by_content_id", (q) => q.eq("content_id", view.content_id)) - .unique(); + while (subjects.length < limit && pagesRead < recentLearningMaxPages) { + const recentRows = yield* Effect.tryPromise({ + try: () => + ctx.db + .query("userLearningRecents") + .withIndex( + "by_userId_and_locale_and_section_and_lastViewedAt", + (q) => + q + .eq("userId", user.appUser._id) + .eq("locale", args.locale) + .eq("section", "material") + ) + .order("desc") + .paginate({ + cursor, + numItems: recentLearningPageSize, + }), + catch: toRecentLearningIoError, + }); + + pagesRead += 1; - if (route?.kind !== "curriculum-lesson") { - continue; + for (const row of recentRows.page) { + const subject = yield* toRecentlyViewedSubject(ctx.db, row); + + if (subject) { + subjects.push(subject); + } + + if (subjects.length >= limit) { + break; + } } - if (!route.materialDomain) { - continue; + if (recentRows.isDone || subjects.length >= limit) { + break; } - results.push({ - ...toPublicContentRef(route), - description: route.description ?? "", - lastViewedAt: view.lastViewedAt, - materialDomain: route.materialDomain, - title: route.title, - }); + cursor = recentRows.continueCursor; } - return results; - }, + return subjects; + } +); + +/** Returns the current user's recently viewed subjects for one locale. */ +export const getRecentlyViewed = query({ + args: getRecentlyViewedArgs, + returns: vv.array(recentlyViewedSubjectValidator), + handler: async (ctx, args) => + await runConvexProgram(listRecentLearning(ctx, args)), }); /** Exposes only public content-ref fields accepted by the recent-view validator. */ @@ -87,3 +144,38 @@ function toPublicContentRef( url: ref.url, }; } + +/** Projects one ranked recent row to the public home-card result shape. */ +const toRecentlyViewedSubject = Effect.fn( + "contents.recent.toRecentlyViewedSubject" +)(function* (db: QueryCtx["db"], row: Doc<"userLearningRecents">) { + const route = yield* Effect.tryPromise({ + try: () => + db + .query("contentRoutes") + .withIndex("by_content_id", (q) => q.eq("content_id", row.content_id)) + .unique(), + catch: toRecentLearningIoError, + }); + + if ( + !( + route && + route.locale === row.locale && + route.kind === "curriculum-lesson" && + route.materialDomain + ) + ) { + return; + } + + return { + ...toPublicContentRef(route), + contextKey: row.contextKey, + description: route.description ?? "", + href: `/${cleanSlug(route.route)}${toLearningContextQuery(row)}`, + lastViewedAt: row.lastViewedAt, + materialDomain: route.materialDomain, + title: route.title, + }; +}); diff --git a/packages/backend/convex/contents/rankings.ts b/packages/backend/convex/contents/rankings.ts new file mode 100644 index 0000000000..5650c42e47 --- /dev/null +++ b/packages/backend/convex/contents/rankings.ts @@ -0,0 +1,35 @@ +import { TableAggregate } from "@convex-dev/aggregate"; +import { components } from "@repo/backend/convex/_generated/api"; +import type { DataModel, Doc } from "@repo/backend/convex/_generated/dataModel"; + +type LearningPopularityCounter = Doc<"learningPopularityCounters">; + +export type LearningPopularityRankingNamespace = [ + LearningPopularityCounter["section"], + LearningPopularityCounter["locale"], + LearningPopularityCounter["scopeMode"], + LearningPopularityCounter["windowKey"], +]; + +/** + * Aggregate-backed ranking index for windowed learning popularity counters. + * + * The namespace matches the homepage access pattern exactly, so top-N reads do + * not scan counters from other sections, locales, scopes, or windows. Negative + * score sorts the aggregate in highest-score-first order with the content ID as + * a stable tie breaker. + */ +export const learningPopularityRankings = new TableAggregate<{ + Namespace: LearningPopularityRankingNamespace; + Key: [number, string]; + DataModel: DataModel; + TableName: "learningPopularityCounters"; +}>(components.learningPopularityRankings, { + namespace: (counter) => [ + counter.section, + counter.locale, + counter.scopeMode, + counter.windowKey, + ], + sortKey: (counter) => [-counter.score, counter.content_id], +}); diff --git a/packages/backend/convex/contents/runtime/catalog.ts b/packages/backend/convex/contents/runtime/catalog.ts index cb64bf3351..0e49fee260 100644 --- a/packages/backend/convex/contents/runtime/catalog.ts +++ b/packages/backend/convex/contents/runtime/catalog.ts @@ -430,7 +430,10 @@ function toRuntimePublicRoute(route: Doc<"publicRoutes">) { displayGroupTitle: route.displayGroupTitle, iconKey: route.iconKey, kind: route.kind, + level: route.level, locale: route.locale, + materialCardDescription: route.materialCardDescription, + materialCardTitle: route.materialCardTitle, materialDomain: route.materialDomain, materialKey: route.materialKey, nodeKey: route.nodeKey, diff --git a/packages/backend/convex/contents/runtime/routes.ts b/packages/backend/convex/contents/runtime/routes.ts index 4715189389..69dd432b12 100644 --- a/packages/backend/convex/contents/runtime/routes.ts +++ b/packages/backend/convex/contents/runtime/routes.ts @@ -2,7 +2,10 @@ import { localeValidator, materialValidator, } from "@repo/backend/convex/lib/validators/contents"; -import { PROGRAM_NAVIGATION_ICON_KEY_VALUES } from "@repo/contents/_types/program/schema"; +import { + PROGRAM_NAVIGATION_ICON_KEY_VALUES, + PROGRAM_NAVIGATION_LEVEL_VALUES, +} from "@repo/contents/_types/program/schema"; import { PUBLIC_ROUTE_KIND_VALUES } from "@repo/contents/_types/route/schema"; import { type Infer, v } from "convex/values"; import { literals, nullable } from "convex-helpers/validators"; @@ -11,6 +14,7 @@ const publicRouteKindValidator = literals(...PUBLIC_ROUTE_KIND_VALUES); const navigationIconKeyValidator = literals( ...PROGRAM_NAVIGATION_ICON_KEY_VALUES ); +const navigationLevelValidator = literals(...PROGRAM_NAVIGATION_LEVEL_VALUES); const runtimePublicRouteValidator = v.object({ canonicalPath: v.optional(v.string()), @@ -19,7 +23,10 @@ const runtimePublicRouteValidator = v.object({ displayGroupTitle: v.optional(v.string()), iconKey: v.optional(navigationIconKeyValidator), kind: publicRouteKindValidator, + level: v.optional(navigationLevelValidator), locale: localeValidator, + materialCardDescription: v.optional(v.string()), + materialCardTitle: v.optional(v.string()), materialDomain: v.optional(materialValidator), materialKey: v.optional(v.string()), nodeKey: v.optional(v.string()), diff --git a/packages/backend/convex/contents/schema.ts b/packages/backend/convex/contents/schema.ts index 3d1ebb297f..901b35a2d1 100644 --- a/packages/backend/convex/contents/schema.ts +++ b/packages/backend/convex/contents/schema.ts @@ -1,15 +1,23 @@ import { CONTENT_ROUTE_KINDS } from "@repo/backend/convex/contents/constants"; +import { learningContextStorageFields } from "@repo/backend/convex/contents/context"; import { graphContentIdValidator, learningGraphIdentityValidator, } from "@repo/backend/convex/contents/graph"; import { contentSearchDocumentValidator } from "@repo/backend/convex/contents/helpers/search/schema"; +import { + learningPopularityScopeValues, + learningPopularityWindowValues, +} from "@repo/backend/convex/contents/popularity"; import { localeValidator, materialValidator, nakafaSectionValidator, } from "@repo/backend/convex/lib/validators/contents"; -import { PROGRAM_NAVIGATION_ICON_KEY_VALUES } from "@repo/contents/_types/program/schema"; +import { + PROGRAM_NAVIGATION_ICON_KEY_VALUES, + PROGRAM_NAVIGATION_LEVEL_VALUES, +} from "@repo/contents/_types/program/schema"; import { PUBLIC_ROUTE_KIND_VALUES } from "@repo/contents/_types/route/schema"; import { defineTable } from "convex/server"; import { v } from "convex/values"; @@ -19,7 +27,14 @@ const contentRouteKindValidator = literals(...CONTENT_ROUTE_KINDS); const navigationIconKeyValidator = literals( ...PROGRAM_NAVIGATION_ICON_KEY_VALUES ); +const navigationLevelValidator = literals(...PROGRAM_NAVIGATION_LEVEL_VALUES); const publicRouteKindValidator = literals(...PUBLIC_ROUTE_KIND_VALUES); +const learningPopularityWindowValidator = literals( + ...learningPopularityWindowValues +); +const learningPopularityScopeValidator = literals( + ...learningPopularityScopeValues +); const contentRoutePageItemValidator = v.object({ ...learningGraphIdentityValidator.fields, authors: v.array(v.object({ name: v.string() })), @@ -43,13 +58,14 @@ const contentRoutePageItemValidator = v.object({ const tables = { /** - * Graph-backed content view read model. - * One record per user/device per content. - * Tracks first and last view timestamps for engagement analytics. + * Graph-backed learning engagement read model. + * One record per anonymous device or authenticated user-device for each + * canonical asset and verified context. * `route` is a display/navigation projection; `content_id` is the graph asset ID. */ - contentViews: defineTable({ + learningViews: defineTable({ ...learningGraphIdentityValidator.fields, + ...learningContextStorageFields, content_id: graphContentIdValidator, deviceId: v.string(), firstViewedAt: v.number(), @@ -59,14 +75,34 @@ const tables = { section: nakafaSectionValidator, userId: v.optional(v.id("users")), }) - .index("by_userId_and_content_id", ["userId", "content_id"]) + .index("by_userId_and_content_id_and_contextKey", [ + "userId", + "content_id", + "contextKey", + ]) + .index("by_userId_and_deviceId_and_content_id_and_contextKey", [ + "userId", + "deviceId", + "content_id", + "contextKey", + ]) .index("by_userId_and_section_and_locale_and_lastViewedAt", [ "userId", "section", "locale", "lastViewedAt", ]) - .index("by_deviceId_and_content_id", ["deviceId", "content_id"]) + .index("by_deviceId_and_content_id_and_contextKey", [ + "deviceId", + "content_id", + "contextKey", + ]) + .index("by_deviceId_and_content_id_and_contextKey_and_lastViewedAt", [ + "deviceId", + "content_id", + "contextKey", + "lastViewedAt", + ]) .index("by_locale_and_section_and_lastViewedAt", [ "locale", "section", @@ -74,19 +110,56 @@ const tables = { ]), /** - * Append-only queue of new unique views. + * Append-only queue of new unique learning engagement events. * Queue rows are partitioned so background processors can drain them in parallel. * Rows carry graph identity so analytics never resolves product identity from routes. */ - contentViewAnalyticsQueue: defineTable({ + learningEngagementQueue: defineTable({ ...learningGraphIdentityValidator.fields, + ...learningContextStorageFields, content_id: graphContentIdValidator, + description: v.optional(v.string()), + insertedAt: v.number(), locale: localeValidator, + materialDomain: v.optional(materialValidator), partition: v.number(), route: v.string(), section: nakafaSectionValidator, + scopeMode: learningPopularityScopeValidator, + sourcePath: v.string(), + title: v.string(), + viewerKey: v.string(), viewedAt: v.number(), - }).index("by_partition", ["partition"]), + }).index("by_partition_and_insertedAt", ["partition", "insertedAt"]), + + /** + * Continue Learning read model ranked by the learner's latest verified view. + */ + userLearningRecents: defineTable({ + ...learningGraphIdentityValidator.fields, + ...learningContextStorageFields, + content_id: graphContentIdValidator, + description: v.optional(v.string()), + lastViewedAt: v.number(), + locale: localeValidator, + materialDomain: v.optional(materialValidator), + route: v.string(), + section: nakafaSectionValidator, + sourcePath: v.string(), + title: v.string(), + userId: v.id("users"), + }) + .index("by_userId_and_content_id_and_contextKey", [ + "userId", + "content_id", + "contextKey", + ]) + .index("by_userId_and_locale_and_section_and_lastViewedAt", [ + "userId", + "locale", + "section", + "lastViewedAt", + ]), /** * Lease rows for partitioned analytics queue processing. @@ -100,46 +173,103 @@ const tables = { }).index("by_partition", ["partition"]), /** - * Daily graph-backed learning trend buckets. - * One row per section, locale, graph asset ID, and UTC day bucket. + * Daily viewer de-duplication for popularity signals. + * One row means that viewer already contributed to the scope on that day. + */ + learningPopularityViewerSignals: defineTable({ + ...learningGraphIdentityValidator.fields, + ...learningContextStorageFields, + content_id: graphContentIdValidator, + locale: localeValidator, + scopeMode: learningPopularityScopeValidator, + section: nakafaSectionValidator, + signalDay: v.number(), + viewedAt: v.number(), + viewerKey: v.string(), + }) + .index("by_viewer_content_day_scope_context", [ + "viewerKey", + "content_id", + "signalDay", + "scopeMode", + "contextKey", + ]) + .index("by_section_and_locale_and_scopeMode_and_signalDay", [ + "section", + "locale", + "scopeMode", + "signalDay", + ]), + + /** + * Daily verified popularity signals used for audited window rebuilds. */ - learningTrendingBuckets: defineTable({ - bucketStart: v.number(), + learningPopularitySignals: defineTable({ ...learningGraphIdentityValidator.fields, + ...learningContextStorageFields, content_id: graphContentIdValidator, + description: v.optional(v.string()), locale: localeValidator, + materialDomain: v.optional(materialValidator), + route: v.string(), section: nakafaSectionValidator, + scopeMode: learningPopularityScopeValidator, + signalDay: v.number(), + sourcePath: v.string(), + title: v.string(), updatedAt: v.number(), viewCount: v.number(), - }).index("by_section_and_locale_and_bucketStart_and_content_id", [ - "section", - "locale", - "bucketStart", - "content_id", - ]), + }) + .index("by_scopeMode_and_signalDay_and_content_id_and_contextKey", [ + "scopeMode", + "signalDay", + "content_id", + "contextKey", + ]) + .index("by_scopeMode_and_content_id_and_contextKey_and_signalDay", [ + "scopeMode", + "content_id", + "contextKey", + "signalDay", + ]) + .index("by_section_and_locale_and_scopeMode_and_signalDay", [ + "section", + "locale", + "scopeMode", + "signalDay", + ]), /** - * Graph-backed learning popularity read model. - * One row per graph asset ID; `section` and `locale` are query dimensions. + * Ranked popularity read model for bounded homepage and route queries. */ - learningPopularity: defineTable({ + learningPopularityCounters: defineTable({ ...learningGraphIdentityValidator.fields, + ...learningContextStorageFields, content_id: graphContentIdValidator, + description: v.optional(v.string()), locale: localeValidator, + materialDomain: v.optional(materialValidator), + route: v.string(), + score: v.number(), section: nakafaSectionValidator, - viewCount: v.number(), + scopeMode: learningPopularityScopeValidator, + sourcePath: v.string(), + title: v.string(), updatedAt: v.number(), + windowKey: learningPopularityWindowValidator, }) - .index("by_content_id", ["content_id"]) - .index("by_section_and_viewCount_and_content_id", [ - "section", - "viewCount", + .index("by_windowKey_and_scopeMode_and_content_id_and_contextKey", [ + "windowKey", + "scopeMode", "content_id", + "contextKey", ]) - .index("by_section_and_locale_and_viewCount_and_content_id", [ + .index("by_section_locale_scope_window_score_id", [ "section", "locale", - "viewCount", + "scopeMode", + "windowKey", + "score", "content_id", ]), @@ -260,7 +390,10 @@ const tables = { displayGroupTitle: v.optional(v.string()), iconKey: v.optional(navigationIconKeyValidator), kind: publicRouteKindValidator, + level: v.optional(navigationLevelValidator), locale: localeValidator, + materialCardDescription: v.optional(v.string()), + materialCardTitle: v.optional(v.string()), materialDomain: v.optional(materialValidator), materialKey: v.optional(v.string()), nodeKey: v.optional(v.string()), @@ -311,6 +444,7 @@ const tables = { "publicPath", ]) .index("by_materialKey_and_locale", ["materialKey", "locale"]) + .index("by_locale_and_sourcePath", ["locale", "sourcePath"]) .index("by_locale_and_sitemap_and_publicPath", [ "locale", "sitemap", diff --git a/packages/backend/convex/contents/views/context.ts b/packages/backend/convex/contents/views/context.ts new file mode 100644 index 0000000000..67db33a528 --- /dev/null +++ b/packages/backend/convex/contents/views/context.ts @@ -0,0 +1,155 @@ +import type { Doc } from "@repo/backend/convex/_generated/dataModel"; +import type { MutationCtx } from "@repo/backend/convex/_generated/server"; +import { + createCanonicalLearningContext, + createContextKey, + type LearningContextInput, + type LearningContextStorage, +} from "@repo/backend/convex/contents/context"; +import { + ContentViewIoError, + contentViewIoFailedCode, +} from "@repo/backend/convex/contents/views/spec"; +import { getUnknownErrorMessage } from "@repo/backend/convex/lib/effect"; +import { Effect } from "effect"; + +const MAX_CONTEXT_ROUTES_PER_MATERIAL = 100; + +/** Maps thrown Convex IO failures into the content-view error channel. */ +function toContextIoError(error: unknown) { + return new ContentViewIoError({ + code: contentViewIoFailedCode, + message: getUnknownErrorMessage(error), + }); +} + +/** Resolves the public material route that corresponds to a graph route row. */ +const loadPublicMaterialRoute = Effect.fn( + "contents.views.context.loadPublicMaterialRoute" +)(function* (db: MutationCtx["db"], route: Doc<"contentRoutes">) { + if (route.kind !== "curriculum-lesson") { + return null; + } + + const publicRoute = yield* Effect.tryPromise({ + try: () => + db + .query("publicRoutes") + .withIndex("by_locale_and_sourcePath", (q) => + q.eq("locale", route.locale).eq("sourcePath", route.sourcePath) + ) + .first(), + catch: toContextIoError, + }); + + if (publicRoute?.kind !== "subject-lesson") { + return null; + } + + if (!publicRoute.materialKey) { + return null; + } + + return publicRoute; +}); + +/** Checks whether a curriculum context route owns the target material route. */ +function matchesMaterialRoute(input: { + readonly contextRoute: Doc<"publicRoutes">; + readonly materialRoute: Doc<"publicRoutes">; +}) { + const canonicalPath = input.contextRoute.canonicalPath; + + if (!canonicalPath) { + return false; + } + + return ( + canonicalPath === input.materialRoute.publicPath || + canonicalPath === input.materialRoute.parentPath + ); +} + +/** Projects a verified public-route context row into engagement storage fields. */ +function toLearningContextStorage(input: { + readonly context: LearningContextInput; + readonly contextRoute: Doc<"publicRoutes">; + readonly materialRoute: Doc<"publicRoutes">; +}): LearningContextStorage { + const nodeKey = input.contextRoute.nodeKey; + const programKey = input.contextRoute.programKey; + + if (!(nodeKey && programKey)) { + return createCanonicalLearningContext(); + } + + return { + contextKey: createContextKey({ + mode: input.context.mode, + nodeKey, + programKey, + }), + contextMaterialKey: input.materialRoute.materialKey, + contextMode: input.context.mode, + contextNodeKey: nodeKey, + contextParentPath: input.contextRoute.parentPath, + contextProgramKey: programKey, + contextPublicPath: input.contextRoute.publicPath, + contextSourcePath: input.materialRoute.sourcePath, + }; +} + +/** + * Verifies optional learning context against durable public route rows. + * + * Invalid, stale, or non-material hints intentionally return canonical context + * so callers do not invent curriculum placement for direct asset visits. + */ +export const resolveLearningContext = Effect.fn( + "contents.views.context.resolveLearningContext" +)(function* ( + db: MutationCtx["db"], + route: Doc<"contentRoutes">, + context: LearningContextInput | undefined +) { + if (!(context?.programKey && context.nodeKey)) { + return createCanonicalLearningContext(); + } + + const materialRoute = yield* loadPublicMaterialRoute(db, route); + + if (!materialRoute) { + return createCanonicalLearningContext(); + } + + const contextRoutes = yield* Effect.tryPromise({ + try: () => + db + .query("publicRoutes") + .withIndex("by_materialKey_and_locale", (q) => + q + .eq("materialKey", materialRoute.materialKey) + .eq("locale", materialRoute.locale) + ) + .take(MAX_CONTEXT_ROUTES_PER_MATERIAL), + catch: toContextIoError, + }); + + const contextRoute = contextRoutes.find( + (candidate) => + candidate.kind === "curriculum-context" && + candidate.programKey === context.programKey && + candidate.nodeKey === context.nodeKey && + matchesMaterialRoute({ contextRoute: candidate, materialRoute }) + ); + + if (!contextRoute) { + return createCanonicalLearningContext(); + } + + return toLearningContextStorage({ + context, + contextRoute, + materialRoute, + }); +}); diff --git a/packages/backend/convex/contents/views/impl.ts b/packages/backend/convex/contents/views/impl.ts index ddcf664d2e..961ac49067 100644 --- a/packages/backend/convex/contents/views/impl.ts +++ b/packages/backend/convex/contents/views/impl.ts @@ -4,7 +4,10 @@ import type { ScheduleContentAnalyticsPartitionArgs, ScheduleContentAnalyticsPartitionResult, } from "@repo/backend/convex/contents/analytics/spec"; -import { getContentAnalyticsPartition } from "@repo/backend/convex/contents/helpers/partitions"; +import type { LearningContextStorage } from "@repo/backend/convex/contents/context"; +import { resolveLearningContext } from "@repo/backend/convex/contents/views/context"; +import { upsertUserRecent } from "@repo/backend/convex/contents/views/recent"; +import { enqueuePopularitySignals } from "@repo/backend/convex/contents/views/signals"; import { ContentViewIoError, contentViewIoFailedCode, @@ -15,15 +18,13 @@ import { getOptionalAppUser } from "@repo/backend/convex/lib/helpers/auth"; import type { FunctionReference } from "convex/server"; import { Clock, Effect } from "effect"; -/** Internal scheduler functions used after a content view is recorded. */ -export interface ContentViewSchedulerTargets { - readonly scheduleAnalyticsPartition: FunctionReference< - "mutation", - "internal", - ScheduleContentAnalyticsPartitionArgs, - ScheduleContentAnalyticsPartitionResult - >; -} +/** Generated internal mutation reference accepted by Convex's scheduler. */ +type ScheduleContentAnalyticsPartitionReference = FunctionReference< + "mutation", + "internal", + ScheduleContentAnalyticsPartitionArgs, + ScheduleContentAnalyticsPartitionResult +>; /** Maps thrown Convex IO failures into the content-view error channel. */ function toContentViewIoError(error: unknown) { @@ -65,65 +66,132 @@ const loadContentTarget = Effect.fn("contents.views.loadContentTarget")( } ); -/** Loads an existing view for either the device or authenticated user. */ -const loadExistingView = Effect.fn("contents.views.loadExistingView")( +/** Loads the latest view row recorded for a device/content/context tuple. */ +const loadLatestDeviceView = Effect.fn("contents.views.loadLatestDeviceView")( function* ( db: MutationCtx["db"], contentId: Doc<"contentRoutes">["content_id"], - input: { - readonly deviceId: string; - readonly userId?: Doc<"users">["_id"]; - } + contextKey: string, + deviceId: string ) { - const existingByDevice = yield* Effect.tryPromise({ + return yield* Effect.tryPromise({ try: () => db - .query("contentViews") - .withIndex("by_deviceId_and_content_id", (q) => - q.eq("deviceId", input.deviceId).eq("content_id", contentId) + .query("learningViews") + .withIndex( + "by_deviceId_and_content_id_and_contextKey_and_lastViewedAt", + (q) => + q + .eq("deviceId", deviceId) + .eq("content_id", contentId) + .eq("contextKey", contextKey) ) + .order("desc") .first(), catch: toContentViewIoError, }); + } +); + +/** Loads the view row owned by an authenticated user on the current device. */ +const loadSignedInDeviceView = Effect.fn( + "contents.views.loadSignedInDeviceView" +)(function* ( + db: MutationCtx["db"], + contentId: Doc<"contentRoutes">["content_id"], + contextKey: string, + input: { + readonly deviceId: string; + readonly userId: Doc<"users">["_id"]; + } +) { + return yield* Effect.tryPromise({ + try: () => + db + .query("learningViews") + .withIndex( + "by_userId_and_deviceId_and_content_id_and_contextKey", + (q) => + q + .eq("userId", input.userId) + .eq("deviceId", input.deviceId) + .eq("content_id", contentId) + .eq("contextKey", contextKey) + ) + .first(), + catch: toContentViewIoError, + }); +}); + +/** + * Loads the only existing view row this request may mutate. + * + * Signed-in requests can touch their exact user-device row or claim an + * anonymous device row. They never mutate a row owned by another signed-in + * learner or a row from another device. + */ +const loadExistingView = Effect.fn("contents.views.loadExistingView")( + function* ( + db: MutationCtx["db"], + contentId: Doc<"contentRoutes">["content_id"], + contextKey: string, + input: { + readonly deviceId: string; + readonly userId?: Doc<"users">["_id"]; + } + ) { + const existingByDevice = yield* loadLatestDeviceView( + db, + contentId, + contextKey, + input.deviceId + ); if (!input.userId) { return existingByDevice; } - const existingByUser = yield* Effect.tryPromise({ - try: () => - db - .query("contentViews") - .withIndex("by_userId_and_content_id", (q) => - q.eq("userId", input.userId).eq("content_id", contentId) - ) - .first(), - catch: toContentViewIoError, - }); + const existingBySignedInDevice = yield* loadSignedInDeviceView( + db, + contentId, + contextKey, + { + deviceId: input.deviceId, + userId: input.userId, + } + ); - return existingByDevice ?? existingByUser; + if (existingBySignedInDevice) { + return existingBySignedInDevice; + } + + if (!existingByDevice?.userId) { + return existingByDevice; + } + + return null; } ); -/** Writes a new view row and its append-only analytics queue row. */ +/** Writes the first durable view row for a viewer/content/context tuple. */ const insertNewView = Effect.fn("contents.views.insertNewView")(function* ( db: MutationCtx["db"], route: Doc<"contentRoutes">, args: RecordContentViewArgs, + context: LearningContextStorage, input: { readonly now: number; readonly userId?: Doc<"users">["_id"]; } ) { - const partition = getContentAnalyticsPartition(route.content_id); - yield* Effect.tryPromise({ try: () => - db.insert("contentViews", { + db.insert("learningViews", { alignmentId: route.alignmentId, assetId: route.assetId, conceptId: route.conceptId, content_id: route.content_id, + ...context, deviceId: args.deviceId, firstViewedAt: input.now, lastViewedAt: input.now, @@ -136,33 +204,13 @@ const insertNewView = Effect.fn("contents.views.insertNewView")(function* ( }), catch: toContentViewIoError, }); - - yield* Effect.tryPromise({ - try: () => - db.insert("contentViewAnalyticsQueue", { - alignmentId: route.alignmentId, - assetId: route.assetId, - conceptId: route.conceptId, - content_id: route.content_id, - learningObjectId: route.learningObjectId, - lensId: route.lensId, - locale: args.locale, - partition, - route: route.route, - section: route.section, - viewedAt: input.now, - }), - catch: toContentViewIoError, - }); - - return partition; }); /** Touches the existing view row and links it to the signed-in user when known. */ const updateExistingView = Effect.fn("contents.views.updateExistingView")( function* ( db: MutationCtx["db"], - view: Doc<"contentViews">, + view: Doc<"learningViews">, input: { readonly now: number; readonly userId?: Doc<"users">["_id"]; @@ -171,7 +219,7 @@ const updateExistingView = Effect.fn("contents.views.updateExistingView")( if (input.userId && !view.userId) { yield* Effect.tryPromise({ try: () => - db.patch("contentViews", view._id, { + db.patch("learningViews", view._id, { lastViewedAt: input.now, userId: input.userId, }), @@ -182,7 +230,7 @@ const updateExistingView = Effect.fn("contents.views.updateExistingView")( yield* Effect.tryPromise({ try: () => - db.patch("contentViews", view._id, { + db.patch("learningViews", view._id, { lastViewedAt: input.now, }), catch: toContentViewIoError, @@ -202,7 +250,7 @@ export const recordUniqueContentView = Effect.fn( )(function* ( ctx: MutationCtx, args: RecordContentViewArgs, - targets: ContentViewSchedulerTargets + scheduleAnalyticsPartition: ScheduleContentAnalyticsPartitionReference ) { const authContext = yield* Effect.tryPromise({ try: () => getOptionalAppUser(ctx), @@ -216,28 +264,90 @@ export const recordUniqueContentView = Effect.fn( const now = yield* Clock.currentTimeMillis; const userId = authContext?.appUser._id; - const existingView = yield* loadExistingView(ctx.db, target.content_id, { - deviceId: args.deviceId, - userId, - }); + const learningContext = yield* resolveLearningContext( + ctx.db, + target, + args.context + ); + const existingView = yield* loadExistingView( + ctx.db, + target.content_id, + learningContext.contextKey, + { + deviceId: args.deviceId, + userId, + } + ); if (existingView) { + // Unsigned repeats can prove device-level dedupe, but must not mutate or + // emit analytics from a row owned by a signed-in learner. + if (!userId && existingView.userId) { + return { alreadyViewed: true, isNewView: false, success: true }; + } + + const popularityUserId = userId ?? existingView.userId; + yield* updateExistingView(ctx.db, existingView, { now, userId }); + if (userId) { + yield* upsertUserRecent(ctx.db, target, learningContext, { + lastViewedAt: now, + userId, + }); + } + + const partitions = yield* enqueuePopularitySignals( + ctx.db, + target, + args, + learningContext, + { + now, + userId: popularityUserId, + } + ); + + for (const partition of partitions) { + yield* Effect.tryPromise({ + try: () => + ctx.scheduler.runAfter(0, scheduleAnalyticsPartition, { partition }), + catch: toContentViewIoError, + }); + } + return { alreadyViewed: true, isNewView: false, success: true }; } - const partition = yield* insertNewView(ctx.db, target, args, { + yield* insertNewView(ctx.db, target, args, learningContext, { now, userId, }); - yield* Effect.tryPromise({ - try: () => - ctx.scheduler.runAfter(0, targets.scheduleAnalyticsPartition, { - partition, - }), - catch: toContentViewIoError, - }); + if (userId) { + yield* upsertUserRecent(ctx.db, target, learningContext, { + lastViewedAt: now, + userId, + }); + } + + const partitions = yield* enqueuePopularitySignals( + ctx.db, + target, + args, + learningContext, + { + now, + userId, + } + ); + + for (const partition of partitions) { + yield* Effect.tryPromise({ + try: () => + ctx.scheduler.runAfter(0, scheduleAnalyticsPartition, { partition }), + catch: toContentViewIoError, + }); + } return { alreadyViewed: false, isNewView: true, success: true }; }); diff --git a/packages/backend/convex/contents/views/recent.ts b/packages/backend/convex/contents/views/recent.ts new file mode 100644 index 0000000000..7e26c0bd09 --- /dev/null +++ b/packages/backend/convex/contents/views/recent.ts @@ -0,0 +1,75 @@ +import type { Doc } from "@repo/backend/convex/_generated/dataModel"; +import type { MutationCtx } from "@repo/backend/convex/_generated/server"; +import type { LearningContextStorage } from "@repo/backend/convex/contents/context"; +import { + ContentViewIoError, + contentViewIoFailedCode, +} from "@repo/backend/convex/contents/views/spec"; +import { getUnknownErrorMessage } from "@repo/backend/convex/lib/effect"; +import { Effect } from "effect"; + +/** Maps thrown Convex IO failures into the content-view error channel. */ +function toRecentIoError(error: unknown) { + return new ContentViewIoError({ + code: contentViewIoFailedCode, + message: getUnknownErrorMessage(error), + }); +} + +/** Upserts the signed-in learner's recent content read-model row. */ +export const upsertUserRecent = Effect.fn("contents.views.upsertUserRecent")( + function* ( + db: MutationCtx["db"], + route: Doc<"contentRoutes">, + context: LearningContextStorage, + input: { + readonly lastViewedAt: number; + readonly userId: Doc<"users">["_id"]; + } + ) { + const existing = yield* Effect.tryPromise({ + try: () => + db + .query("userLearningRecents") + .withIndex("by_userId_and_content_id_and_contextKey", (q) => + q + .eq("userId", input.userId) + .eq("content_id", route.content_id) + .eq("contextKey", context.contextKey) + ) + .unique(), + catch: toRecentIoError, + }); + const row = { + alignmentId: route.alignmentId, + assetId: route.assetId, + conceptId: route.conceptId, + content_id: route.content_id, + ...context, + description: route.description, + lastViewedAt: input.lastViewedAt, + learningObjectId: route.learningObjectId, + lensId: route.lensId, + locale: route.locale, + materialDomain: route.materialDomain, + route: route.route, + section: route.section, + sourcePath: route.sourcePath, + title: route.title, + userId: input.userId, + }; + + if (!existing) { + yield* Effect.tryPromise({ + try: () => db.insert("userLearningRecents", row), + catch: toRecentIoError, + }); + return; + } + + yield* Effect.tryPromise({ + try: () => db.patch("userLearningRecents", existing._id, row), + catch: toRecentIoError, + }); + } +); diff --git a/packages/backend/convex/contents/views/signals.ts b/packages/backend/convex/contents/views/signals.ts new file mode 100644 index 0000000000..ab02152967 --- /dev/null +++ b/packages/backend/convex/contents/views/signals.ts @@ -0,0 +1,199 @@ +import type { Doc } from "@repo/backend/convex/_generated/dataModel"; +import type { MutationCtx } from "@repo/backend/convex/_generated/server"; +import { + createCanonicalLearningContext, + type LearningContextStorage, +} from "@repo/backend/convex/contents/context"; +import { getContentAnalyticsPartition } from "@repo/backend/convex/contents/helpers/partitions"; +import { + createPopularityViewerKey, + getPopularitySignalDay, + type LearningPopularityScope, +} from "@repo/backend/convex/contents/popularity"; +import { + ContentViewIoError, + contentViewIoFailedCode, + type RecordContentViewArgs, +} from "@repo/backend/convex/contents/views/spec"; +import { getUnknownErrorMessage } from "@repo/backend/convex/lib/effect"; +import { Effect } from "effect"; + +/** Maps thrown Convex IO failures into the content-view error channel. */ +function toSignalIoError(error: unknown) { + return new ContentViewIoError({ + code: contentViewIoFailedCode, + message: getUnknownErrorMessage(error), + }); +} + +/** Creates one popularity signal scope from verified learning-context storage. */ +function createSignalScope( + context: LearningContextStorage, + scopeMode: LearningPopularityScope +) { + return { context, scopeMode }; +} + +/** Returns the popularity scopes produced by one verified learning context. */ +function createSignalScopes(context: LearningContextStorage) { + const scopes = [ + createSignalScope(createCanonicalLearningContext(), "global"), + ]; + + if (context.contextMode === "placement") { + scopes.push(createSignalScope(context, "placement")); + } + + return scopes; +} + +/** Loads an existing viewer signal for one content/context/day identity. */ +const loadViewerSignal = Effect.fn("contents.views.loadViewerSignal")( + function* ( + db: MutationCtx["db"], + scope: ReturnType[number], + input: { + readonly contentId: Doc<"contentRoutes">["content_id"]; + readonly signalDay: number; + readonly viewerKey: string; + } + ) { + return yield* Effect.tryPromise({ + try: () => + db + .query("learningPopularityViewerSignals") + .withIndex("by_viewer_content_day_scope_context", (q) => + q + .eq("viewerKey", input.viewerKey) + .eq("content_id", input.contentId) + .eq("signalDay", input.signalDay) + .eq("scopeMode", scope.scopeMode) + .eq("contextKey", scope.context.contextKey) + ) + .unique(), + catch: toSignalIoError, + }); + } +); + +/** Inserts one daily popularity signal if the viewer has not contributed yet. */ +const enqueueSignalScope = Effect.fn("contents.views.enqueueSignalScope")( + function* ( + db: MutationCtx["db"], + route: Doc<"contentRoutes">, + args: RecordContentViewArgs, + scope: ReturnType[number], + input: { + readonly now: number; + readonly userId?: Doc<"users">["_id"]; + } + ) { + const signalDay = getPopularitySignalDay(input.now); + const deviceViewerKey = createPopularityViewerKey({ + deviceId: args.deviceId, + }); + const viewerKey = createPopularityViewerKey({ + deviceId: args.deviceId, + userId: input.userId, + }); + const existingSignal = yield* loadViewerSignal(db, scope, { + contentId: route.content_id, + signalDay, + viewerKey, + }); + + if (existingSignal) { + return null; + } + + if (input.userId) { + const existingDeviceSignal = yield* loadViewerSignal(db, scope, { + contentId: route.content_id, + signalDay, + viewerKey: deviceViewerKey, + }); + + if (existingDeviceSignal) { + return null; + } + } + + const partition = getContentAnalyticsPartition( + `${route.content_id}:${scope.scopeMode}:${scope.context.contextKey}` + ); + + yield* Effect.tryPromise({ + try: () => + db.insert("learningPopularityViewerSignals", { + alignmentId: route.alignmentId, + assetId: route.assetId, + conceptId: route.conceptId, + content_id: route.content_id, + ...scope.context, + learningObjectId: route.learningObjectId, + lensId: route.lensId, + locale: args.locale, + scopeMode: scope.scopeMode, + section: route.section, + signalDay, + viewedAt: input.now, + viewerKey, + }), + catch: toSignalIoError, + }); + + yield* Effect.tryPromise({ + try: () => + db.insert("learningEngagementQueue", { + alignmentId: route.alignmentId, + assetId: route.assetId, + conceptId: route.conceptId, + content_id: route.content_id, + ...scope.context, + description: route.description, + insertedAt: input.now, + learningObjectId: route.learningObjectId, + lensId: route.lensId, + locale: args.locale, + materialDomain: route.materialDomain, + partition, + route: route.route, + scopeMode: scope.scopeMode, + section: route.section, + sourcePath: route.sourcePath, + title: route.title, + viewedAt: input.now, + viewerKey, + }), + catch: toSignalIoError, + }); + + return partition; + } +); + +/** Enqueues daily global and placement popularity signals for one view event. */ +export const enqueuePopularitySignals = Effect.fn( + "contents.views.enqueuePopularitySignals" +)(function* ( + db: MutationCtx["db"], + route: Doc<"contentRoutes">, + args: RecordContentViewArgs, + context: LearningContextStorage, + input: { + readonly now: number; + readonly userId?: Doc<"users">["_id"]; + } +) { + const partitions = new Set(); + + for (const scope of createSignalScopes(context)) { + const partition = yield* enqueueSignalScope(db, route, args, scope, input); + + if (partition !== null) { + partitions.add(partition); + } + } + + return [...partitions]; +}); diff --git a/packages/backend/convex/contents/views/spec.ts b/packages/backend/convex/contents/views/spec.ts index dac3af1445..61955feab5 100644 --- a/packages/backend/convex/contents/views/spec.ts +++ b/packages/backend/convex/contents/views/spec.ts @@ -1,3 +1,4 @@ +import { learningContextInputValidator } from "@repo/backend/convex/contents/context"; import { graphContentIdValidator } from "@repo/backend/convex/contents/graph"; import { localeValidator } from "@repo/backend/convex/lib/validators/contents"; import { type Infer, v } from "convex/values"; @@ -7,6 +8,7 @@ export const contentViewIoFailedCode = "CONTENT_VIEW_IO_FAILED"; export const recordContentViewArgs = { contentId: graphContentIdValidator, + context: v.optional(learningContextInputValidator), deviceId: v.string(), locale: localeValidator, }; diff --git a/packages/backend/convex/convex.config.ts b/packages/backend/convex/convex.config.ts index 991e36db01..a56dfeaeb8 100644 --- a/packages/backend/convex/convex.config.ts +++ b/packages/backend/convex/convex.config.ts @@ -40,5 +40,6 @@ app.use(aggregate, { name: "tryoutLeaderboard" }); app.use(aggregate, { name: "globalLeaderboard" }); app.use(aggregate, { name: "forumPostsBySequence" }); app.use(aggregate, { name: "forumPostsByAuthorSequence" }); +app.use(aggregate, { name: "learningPopularityRankings" }); export default app; diff --git a/packages/backend/convex/crons.ts b/packages/backend/convex/crons.ts index 638d286be4..0baa1828d7 100644 --- a/packages/backend/convex/crons.ts +++ b/packages/backend/convex/crons.ts @@ -5,6 +5,7 @@ import { cronJobs } from "convex/server"; const crons = cronJobs(); const CONTENT_ANALYTICS_BACKSTOP_INTERVAL_MINUTES = 10; const CREDIT_RESET_PERIOD_RECONCILE_INTERVAL_MINUTES = 10; +const NINA_CAPABILITY_TRACE_RETENTION_INTERVAL_HOURS = 24; const TRYOUT_EXPIRY_SWEEP_INTERVAL_MINUTES = 5; const TRYOUT_ACCESS_STATUS_SWEEP_INTERVAL_MINUTES = 5; @@ -48,6 +49,26 @@ crons.interval( {} ); +/** + * Rebuilds finite popularity windows from audited daily signals. + */ +crons.cron( + "refresh learning popularity windows", + "15 0 * * *", + internal.contents.mutations.popularity.scheduleLearningPopularityRefreshes, + {} +); + +/** + * Deletes expired derived Nina capability trace summaries in bounded pages. + */ +crons.interval( + "sweep Nina capability traces", + { hours: NINA_CAPABILITY_TRACE_RETENTION_INTERVAL_HOURS }, + internal.chats.traces.mutations.sweepExpired, + {} +); + /** * Populates audio generation queue every 30 minutes. */ diff --git a/packages/backend/convex/curriculumLessons/queries.test.ts b/packages/backend/convex/curriculumLessons/queries.test.ts index 6e9340bfb1..7fb7bb2ca9 100644 --- a/packages/backend/convex/curriculumLessons/queries.test.ts +++ b/packages/backend/convex/curriculumLessons/queries.test.ts @@ -1,153 +1,140 @@ import { api } from "@repo/backend/convex/_generated/api"; +import type { Doc } from "@repo/backend/convex/_generated/dataModel"; import type { MutationCtx } from "@repo/backend/convex/_generated/server"; -import { - invalidTrendingRangeCode, - maxTrendingRangeDays, -} from "@repo/backend/convex/curriculumLessons/trending/spec"; -import { TRENDING_BUCKET_MS } from "@repo/backend/convex/curriculumLessons/utils"; +import { getDefaultPopularityWindow } from "@repo/backend/convex/contents/popularity"; +import { learningPopularityRankings } from "@repo/backend/convex/contents/rankings"; import type { Locale } from "@repo/backend/convex/lib/validators/contents"; import schema from "@repo/backend/convex/schema"; +import { registerLearningPopularityAggregate } from "@repo/backend/convex/test.helpers"; import { convexModules } from "@repo/backend/convex/test.setup"; import { createLearningGraphIdentityFromRoute } from "@repo/contents/_types/learning-graph"; import { convexTest } from "convex-test"; import { describe, expect, it } from "vitest"; const NOW = Date.parse("2026-01-01T00:00:00.000Z"); +const canonicalContext = { + contextKey: "canonical", + contextMode: "canonical", +} as const; + +/** + * Builds the Convex test instance with the popularity ranking aggregate + * registered, matching the production top-N query dependency. + */ +function createTrendingConvexTest() { + const t = convexTest(schema, convexModules); + registerLearningPopularityAggregate(t); + return t; +} -/** Inserts one curriculum lesson for trending query tests. */ -async function insertSubject(ctx: MutationCtx, suffix: string) { - const topicId = await ctx.db.insert("curriculumTopics", { - locale: "en", - material: "mathematics", - order: 0, - sectionCount: 1, - slug: `material/lesson/mathematics/topic-${suffix}`, - syncedAt: NOW, - title: `Topic ${suffix}`, - topic: `topic-${suffix}`, - }); +/** Builds the public material route persisted in the popularity read model. */ +function getPublicSubjectRoute(suffix: string) { + return `subjects/mathematics/topic-${suffix}/section-${suffix}`; +} - return await ctx.db.insert("curriculumLessons", { - body: "Subject body", - contentHash: `subject-hash-${suffix}`, - date: NOW, - description: `Description ${suffix}`, - locale: "en", - material: "mathematics", - order: 0, - section: `section-${suffix}`, - slug: `material/lesson/mathematics/topic-${suffix}/section-${suffix}`, - subject: `Topic ${suffix}`, - syncedAt: NOW, - title: `Subject ${suffix}`, - topic: `topic-${suffix}`, - topicId, - }); +/** Builds the authored source route that owns one graph asset identity. */ +function getSourceSubjectRoute(suffix: string) { + return `material/lesson/mathematics/topic-${suffix}/section-${suffix}`; } -/** Inserts the graph route projection for one synced curriculum lesson. */ -async function insertSubjectRoute(ctx: MutationCtx, suffix: string) { - const route = `material/lesson/mathematics/topic-${suffix}/section-${suffix}`; +/** Inserts one ranked global popularity counter row for homepage queries. */ +async function insertSubjectCounter( + ctx: MutationCtx, + input: { + readonly currentSuffix?: string; + readonly locale?: Locale; + readonly materialDomain?: Doc<"learningPopularityCounters">["materialDomain"]; + readonly score: number; + readonly suffix: string; + readonly windowKey?: Doc<"learningPopularityCounters">["windowKey"]; + } +) { + const locale = input.locale ?? "en"; + const sourcePath = getSourceSubjectRoute(input.suffix); const identity = createLearningGraphIdentityFromRoute({ - locale: "en", - route, + locale, + route: sourcePath, }); if (!identity) { - expect.fail(`Expected subject graph identity for ${route}.`); + expect.fail(`Expected subject graph identity for ${sourcePath}.`); } + const currentSuffix = input.currentSuffix ?? input.suffix; + await ctx.db.insert("contentRoutes", { ...identity, - authors: [{ name: "Nakafa Author" }], - contentHash: `subject-hash-${suffix}`, + authors: [], + contentHash: `route-hash-${input.suffix}`, content_id: identity.assetId, - date: NOW, - description: `Description ${suffix}`, kind: "curriculum-lesson", - locale: "en", + locale, markdown: true, - materialDomain: "mathematics", - route: getPublicSubjectRoute(suffix), + ...(input.materialDomain ? { materialDomain: input.materialDomain } : {}), + route: getPublicSubjectRoute(currentSuffix), section: "material", - sourcePath: route, + sourcePath, syncedAt: NOW, - title: `Subject ${suffix}`, + title: `Subject ${currentSuffix}`, }); - return identity; -} - -/** Builds the public material route stored in the route read model. */ -function getPublicSubjectRoute(suffix: string) { - return `subjects/mathematics/topic-${suffix}/section-${suffix}`; -} - -/** Inserts one derived trending bucket row. */ -async function insertTrendingBucket( - ctx: MutationCtx, - graph: Awaited>, - { - bucketStart, - locale = "en", - viewCount, - }: { - bucketStart: number; - locale?: Locale; - viewCount: number; - } -) { - await ctx.db.insert("learningTrendingBuckets", { - ...graph, - bucketStart, - content_id: graph.assetId, + const counterId = await ctx.db.insert("learningPopularityCounters", { + ...identity, + ...canonicalContext, + content_id: identity.assetId, + ...(input.materialDomain ? { materialDomain: input.materialDomain } : {}), locale, + route: getPublicSubjectRoute(input.suffix), + score: input.score, section: "material", + scopeMode: "global", + sourcePath, + title: `Subject ${input.suffix}`, updatedAt: NOW, - viewCount, + windowKey: input.windowKey ?? getDefaultPopularityWindow(), }); + const counter = await ctx.db.get(counterId); + + if (!counter) { + expect.fail(`Expected popularity counter for ${sourcePath}.`); + } + + await learningPopularityRankings.insert(ctx, counter); + + return identity; } describe("curriculumLessons/queries", () => { - it("returns sorted subjects aggregated across bounded daily buckets", async () => { - const t = convexTest(schema, convexModules); - const { firstRef, firstSubjectId, secondRef } = await t.mutation( - async (ctx) => { - const firstSubjectId = await insertSubject(ctx, "first"); - await insertSubject(ctx, "second"); - const firstRef = await insertSubjectRoute(ctx, "first"); - const secondRef = await insertSubjectRoute(ctx, "second"); - - await insertTrendingBucket(ctx, firstRef, { - bucketStart: NOW, - viewCount: 3, - }); - await insertTrendingBucket(ctx, firstRef, { - bucketStart: NOW + TRENDING_BUCKET_MS, - viewCount: 4, - }); - await insertTrendingBucket(ctx, secondRef, { - bucketStart: NOW, - viewCount: 10, - }); - await insertTrendingBucket(ctx, secondRef, { - bucketStart: NOW, - locale: "id", - viewCount: 100, - }); - - return { firstRef, firstSubjectId, secondRef }; - } - ); + it("returns sorted subjects from the bounded windowed read model", async () => { + const t = createTrendingConvexTest(); + const { firstRef, secondRef } = await t.mutation(async (ctx) => { + const firstRef = await insertSubjectCounter(ctx, { + materialDomain: "mathematics", + score: 7, + suffix: "first", + }); + const secondRef = await insertSubjectCounter(ctx, { + materialDomain: "mathematics", + score: 10, + suffix: "second", + }); + await insertSubjectCounter(ctx, { + locale: "id", + materialDomain: "mathematics", + score: 100, + suffix: "ignored-locale", + }); + + return { firstRef, secondRef }; + }); const results = await t.query( api.curriculumLessons.queries.getTrendingSubjects, { locale: "en", - since: NOW, - until: NOW + 2 * TRENDING_BUCKET_MS, limit: 2, minViews: 5, + windowKey: getDefaultPopularityWindow(), } ); @@ -155,6 +142,8 @@ describe("curriculumLessons/queries", () => { expect.objectContaining({ assetId: secondRef.assetId, content_id: secondRef.assetId, + contextKey: "canonical", + href: "/subjects/mathematics/topic-second/section-second", materialDomain: "mathematics", route: "subjects/mathematics/topic-second/section-second", title: "Subject second", @@ -164,6 +153,8 @@ describe("curriculumLessons/queries", () => { expect.objectContaining({ assetId: firstRef.assetId, content_id: firstRef.assetId, + contextKey: "canonical", + href: "/subjects/mathematics/topic-first/section-first", materialDomain: "mathematics", route: "subjects/mathematics/topic-first/section-first", title: "Subject first", @@ -173,83 +164,100 @@ describe("curriculumLessons/queries", () => { ]); expect(results[0]).not.toHaveProperty("id"); expect(results[0]).not.toHaveProperty("slug"); - expect(firstSubjectId).not.toBe(firstRef.assetId); }); - it("returns an empty list for empty or zero-limit ranges", async () => { - const t = convexTest(schema, convexModules); + it("returns an empty list when the caller asks for zero ranked cards", async () => { + const t = createTrendingConvexTest(); - const emptyRange = await t.query( - api.curriculumLessons.queries.getTrendingSubjects, - { - locale: "en", - since: NOW, - until: NOW, - } - ); - const zeroLimit = await t.query( + await t.mutation(async (ctx) => { + await insertSubjectCounter(ctx, { + materialDomain: "mathematics", + score: 10, + suffix: "zero-limit", + }); + }); + + const results = await t.query( api.curriculumLessons.queries.getTrendingSubjects, { locale: "en", - since: NOW, - until: NOW + TRENDING_BUCKET_MS, limit: 0, + windowKey: getDefaultPopularityWindow(), } ); - expect(emptyRange).toEqual([]); - expect(zeroLimit).toEqual([]); + expect(results).toEqual([]); }); - it("keeps bucket rows when the old curriculum lesson row is absent", async () => { - const t = convexTest(schema, convexModules); + it("keeps valid read-model rows even when old source lesson rows are absent", async () => { + const t = createTrendingConvexTest(); await t.mutation(async (ctx) => { - const subjectId = await insertSubject(ctx, "deleted"); - const ref = await insertSubjectRoute(ctx, "deleted"); - await insertTrendingBucket(ctx, ref, { - bucketStart: NOW, - viewCount: 10, + await insertSubjectCounter(ctx, { + materialDomain: "mathematics", + score: 10, + suffix: "source-free", }); - await ctx.db.delete("curriculumLessons", subjectId); }); const results = await t.query( api.curriculumLessons.queries.getTrendingSubjects, { locale: "en", - since: NOW, - until: NOW + TRENDING_BUCKET_MS, + minViews: 5, + windowKey: getDefaultPopularityWindow(), } ); expect(results).toEqual([ expect.objectContaining({ materialDomain: "mathematics", - route: "subjects/mathematics/topic-deleted/section-deleted", + route: "subjects/mathematics/topic-source-free/section-source-free", }), ]); }); - it("drops bucket rows whose route projection lacks a material domain", async () => { - const t = convexTest(schema, convexModules); + it("hydrates stale counter routes from the current public route row", async () => { + const t = createTrendingConvexTest(); + const { currentRef } = await t.mutation(async (ctx) => { + const currentRef = await insertSubjectCounter(ctx, { + currentSuffix: "current-route", + materialDomain: "mathematics", + score: 10, + suffix: "stale-counter", + }); - await t.mutation(async (ctx) => { - await insertSubject(ctx, "missing-domain"); - const ref = await insertSubjectRoute(ctx, "missing-domain"); - const route = await ctx.db - .query("contentRoutes") - .withIndex("by_content_id", (q) => q.eq("content_id", ref.assetId)) - .unique(); - - if (!route) { - expect.fail("Expected route projection before removing domain."); + return { currentRef }; + }); + + const results = await t.query( + api.curriculumLessons.queries.getTrendingSubjects, + { + locale: "en", + minViews: 5, + windowKey: getDefaultPopularityWindow(), } + ); - await ctx.db.patch(route._id, { materialDomain: undefined }); - await insertTrendingBucket(ctx, ref, { - bucketStart: NOW, - viewCount: 10, + expect(results).toEqual([ + expect.objectContaining({ + assetId: currentRef.assetId, + content_id: currentRef.assetId, + href: "/subjects/mathematics/topic-current-route/section-current-route", + route: "subjects/mathematics/topic-current-route/section-current-route", + title: "Subject current-route", + url: "https://nakafa.com/en/subjects/mathematics/topic-current-route/section-current-route", + }), + ]); + }); + + it("drops counter rows whose read-model projection lacks a material domain", async () => { + const t = createTrendingConvexTest(); + + await t.mutation(async (ctx) => { + await insertSubjectCounter(ctx, { + score: 10, + suffix: "missing-domain", }); }); @@ -257,33 +265,50 @@ describe("curriculumLessons/queries", () => { api.curriculumLessons.queries.getTrendingSubjects, { locale: "en", - since: NOW, - until: NOW + TRENDING_BUCKET_MS, + minViews: 5, + windowKey: getDefaultPopularityWindow(), } ); expect(results).toEqual([]); }); - it("drops bucket rows whose subject lacks a graph route projection", async () => { - const t = convexTest(schema, convexModules); + it("drops practice counters from Trending Subjects cards", async () => { + const t = createTrendingConvexTest(); await t.mutation(async (ctx) => { - await insertSubject(ctx, "missing-route"); - const route = - "material/lesson/mathematics/topic-missing-route/section-missing-route"; - const ref = createLearningGraphIdentityFromRoute({ - locale: "en", - route, + await insertPracticeCounter(ctx, { + materialDomain: "mathematics", + score: 50, + suffix: "practice", }); + }); - if (!ref) { - throw new Error(`Expected subject graph identity for ${route}.`); + const results = await t.query( + api.curriculumLessons.queries.getTrendingSubjects, + { + locale: "en", + minViews: 5, + windowKey: getDefaultPopularityWindow(), } + ); - await insertTrendingBucket(ctx, ref, { - bucketStart: NOW, - viewCount: 10, + expect(results).toEqual([]); + }); + + it("pages past filtered practice counters to fill Trending Subjects", async () => { + const t = createTrendingConvexTest(); + const validRef = await t.mutation(async (ctx) => { + await insertPracticeCounter(ctx, { + materialDomain: "mathematics", + score: 100, + suffix: "practice-page", + }); + + return await insertSubjectCounter(ctx, { + materialDomain: "mathematics", + score: 50, + suffix: "valid-page", }); }); @@ -291,28 +316,88 @@ describe("curriculumLessons/queries", () => { api.curriculumLessons.queries.getTrendingSubjects, { locale: "en", - since: NOW, - until: NOW + TRENDING_BUCKET_MS, + limit: 1, + minViews: 5, + windowKey: getDefaultPopularityWindow(), } ); - expect(results).toEqual([]); + expect(results).toEqual([ + expect.objectContaining({ + assetId: validRef.assetId, + route: "subjects/mathematics/topic-valid-page/section-valid-page", + title: "Subject valid-page", + viewCount: 50, + }), + ]); }); +}); - it("rejects ranges wider than the supported trending window", async () => { - const t = convexTest(schema, convexModules); +/** Inserts one ranked practice counter that must not hydrate as a subject. */ +async function insertPracticeCounter( + ctx: MutationCtx, + input: { + readonly materialDomain?: Doc<"learningPopularityCounters">["materialDomain"]; + readonly score: number; + readonly suffix: string; + } +) { + const sourcePath = getSourcePracticeRoute(input.suffix); + const identity = createLearningGraphIdentityFromRoute({ + locale: "en", + route: sourcePath, + }); - await expect( - t.query(api.curriculumLessons.queries.getTrendingSubjects, { - locale: "en", - since: NOW, - until: NOW + (maxTrendingRangeDays + 1) * TRENDING_BUCKET_MS, - }) - ).rejects.toMatchObject({ - data: { - code: invalidTrendingRangeCode, - message: `Trending range cannot exceed ${maxTrendingRangeDays} days.`, - }, - }); + if (!identity) { + expect.fail(`Expected practice graph identity for ${sourcePath}.`); + } + + await ctx.db.insert("contentRoutes", { + ...identity, + authors: [], + contentHash: `practice-route-hash-${input.suffix}`, + content_id: identity.assetId, + kind: "exercise-set", + locale: "en", + markdown: true, + ...(input.materialDomain ? { materialDomain: input.materialDomain } : {}), + route: getPublicPracticeRoute(input.suffix), + section: "material", + sourcePath, + syncedAt: NOW, + title: `Practice ${input.suffix}`, }); -}); + + const counterId = await ctx.db.insert("learningPopularityCounters", { + ...identity, + ...canonicalContext, + content_id: identity.assetId, + ...(input.materialDomain ? { materialDomain: input.materialDomain } : {}), + locale: "en", + route: getPublicPracticeRoute(input.suffix), + score: input.score, + section: "material", + scopeMode: "global", + sourcePath, + title: `Practice ${input.suffix}`, + updatedAt: NOW, + windowKey: getDefaultPopularityWindow(), + }); + const counter = await ctx.db.get(counterId); + + if (!counter) { + expect.fail(`Expected popularity counter for ${sourcePath}.`); + } + + await learningPopularityRankings.insert(ctx, counter); +} + +/** Builds the authored source route for one practice counter fixture. */ +function getSourcePracticeRoute(suffix: string) { + return `material/practice/assessment/snbt/quantitative-knowledge/${suffix}/set-1`; +} + +/** Builds the public route for one practice counter fixture. */ +function getPublicPracticeRoute(suffix: string) { + return `practice/assessment/snbt/quantitative-knowledge/${suffix}/set-1`; +} diff --git a/packages/backend/convex/curriculumLessons/trending/impl.ts b/packages/backend/convex/curriculumLessons/trending/impl.ts index ca38f9fb63..77c202f160 100644 --- a/packages/backend/convex/curriculumLessons/trending/impl.ts +++ b/packages/backend/convex/curriculumLessons/trending/impl.ts @@ -1,24 +1,27 @@ import type { Doc } from "@repo/backend/convex/_generated/dataModel"; import type { QueryCtx } from "@repo/backend/convex/_generated/server"; +import { toLearningContextQuery } from "@repo/backend/convex/contents/context"; import { buildContentSearchRef } from "@repo/backend/convex/contents/helpers/search/documents"; +import { + getDefaultPopularityWindow, + type LearningPopularityWindow, +} from "@repo/backend/convex/contents/popularity"; +import { learningPopularityRankings } from "@repo/backend/convex/contents/rankings"; import { type GetTrendingSubjectsArgs, - InvalidTrendingRangeError, - invalidTrendingRangeCode, - maxTrendingRangeDays, maxTrendingSubjectsLimit, TrendingSubjectIoError, trendingSubjectIoFailedCode, } from "@repo/backend/convex/curriculumLessons/trending/spec"; -import { - getTrendingBucketStart, - TRENDING_BUCKET_MS, -} from "@repo/backend/convex/curriculumLessons/utils"; import { getUnknownErrorMessage } from "@repo/backend/convex/lib/effect"; +import type { TrendingSubject } from "@repo/backend/convex/lib/validators/trending"; +import { cleanSlug } from "@repo/utilities/helper"; +import { getAll } from "convex-helpers/server/relationships"; import { Effect } from "effect"; const defaultTrendingSubjectsLimit = 6; const defaultTrendingMinViews = 5; +const trendingRankingMaxPages = 5; /** Maps thrown Convex IO failures into the trending-subject error channel. */ function toTrendingSubjectIoError(error: unknown) { @@ -28,137 +31,109 @@ function toTrendingSubjectIoError(error: unknown) { }); } -/** Computes a bounded, day-bucketed trending query window. */ -function getTrendingWindow(args: GetTrendingSubjectsArgs) { - if (args.until <= args.since) { - return null; - } - - const maxRangeMs = maxTrendingRangeDays * TRENDING_BUCKET_MS; - - if (args.until - args.since > maxRangeMs) { - return new InvalidTrendingRangeError({ - code: invalidTrendingRangeCode, - message: `Trending range cannot exceed ${maxTrendingRangeDays} days.`, - }); - } - - const since = getTrendingBucketStart(args.since); - const until = - getTrendingBucketStart(Math.max(args.since, args.until - 1)) + - TRENDING_BUCKET_MS; - - return { since, until }; -} - /** Normalizes caller-provided result filters to bounded query settings. */ function getTrendingSettings(args: GetTrendingSubjectsArgs) { const rawLimit = args.limit ?? defaultTrendingSubjectsLimit; const rawMinViews = args.minViews ?? defaultTrendingMinViews; const limit = Math.min(Math.max(rawLimit, 0), maxTrendingSubjectsLimit); const minViews = Math.max(rawMinViews, 0); + const windowKey = args.windowKey ?? getDefaultPopularityWindow(); - return { limit, minViews }; + return { limit, minViews, windowKey }; } -/** Reads daily trending bucket counts inside one indexed locale/range scan. */ -const loadSubjectViewCounts = Effect.fn( - "curriculumLessons.trending.loadSubjectViewCounts" +/** Reads aggregate-ranked counter IDs for one homepage popularity namespace. */ +const loadRankedPopularityCounterIds = Effect.fn( + "curriculumLessons.trending.loadRankedPopularityCounterIds" )(function* ( ctx: QueryCtx, args: GetTrendingSubjectsArgs, - window: { - readonly since: number; - readonly until: number; - } -) { - return yield* Effect.tryPromise({ - try: async () => { - const bucketsInRange = ctx.db - .query("learningTrendingBuckets") - .withIndex( - "by_section_and_locale_and_bucketStart_and_content_id", - (q) => - q - .eq("section", "material") - .eq("locale", args.locale) - .gte("bucketStart", window.since) - .lt("bucketStart", window.until) - ); - - const countBySubject = new Map< - Doc<"contentRoutes">["content_id"], - number - >(); - - for await (const bucket of bucketsInRange) { - countBySubject.set( - bucket.content_id, - (countBySubject.get(bucket.content_id) ?? 0) + bucket.viewCount - ); - } - - return countBySubject; - }, - catch: toTrendingSubjectIoError, - }); -}); - -/** Builds the sorted top-subject entries from aggregated daily bucket counts. */ -function getTopTrendingEntries( - countBySubject: ReadonlyMap["content_id"], number>, settings: { readonly limit: number; readonly minViews: number; + readonly windowKey: LearningPopularityWindow; } ) { - return Array.from(countBySubject.entries()) - .filter(([, count]) => count >= settings.minViews) - .sort((a, b) => b[1] - a[1]) - .slice(0, settings.limit); -} + const ids: Doc<"learningPopularityCounters">["_id"][] = []; + let cursor: string | undefined; + let pagesRead = 0; + + while (pagesRead < trendingRankingMaxPages) { + const result = yield* Effect.tryPromise({ + try: () => + learningPopularityRankings.paginate(ctx, { + namespace: ["material", args.locale, "global", settings.windowKey], + order: "asc", + pageSize: settings.limit, + ...(cursor ? { cursor } : {}), + }), + catch: toTrendingSubjectIoError, + }); + + ids.push(...result.page.map((item) => item.id)); + pagesRead += 1; -/** Loads subject documents and preserves the already-sorted trending order. */ -const buildTrendingSubjects = Effect.fn( - "curriculumLessons.trending.buildTrendingSubjects" + if (result.isDone) { + break; + } + + cursor = result.cursor; + } + + return ids; +}); + +/** Hydrates aggregate IDs back into current counter rows without changing order. */ +const loadRankedPopularityCounters = Effect.fn( + "curriculumLessons.trending.loadRankedPopularityCounters" )(function* ( ctx: QueryCtx, - trendingEntries: readonly (readonly [ - Doc<"contentRoutes">["content_id"], - number, - ])[] + ids: readonly Doc<"learningPopularityCounters">["_id"][] ) { - const results = yield* Effect.forEach( - trendingEntries, - ([contentId, viewCount]) => - loadSubjectRoute(ctx, contentId).pipe( - Effect.flatMap((route) => { - if (!route) { - return Effect.succeed([]); - } - - if (!route.materialDomain) { - return Effect.succeed([]); - } - - return Effect.succeed([ - { - ...toTrendingContentRef(route), - description: route.description ?? "", - materialDomain: route.materialDomain, - title: route.title, - viewCount, - }, - ]); - }) - ) - ); - - return results.flat(); + if (ids.length === 0) { + return []; + } + + const rows = yield* Effect.tryPromise({ + try: () => getAll(ctx.db, "learningPopularityCounters", ids), + catch: toTrendingSubjectIoError, + }); + + return rows.flatMap((row) => (row ? [row] : [])); +}); + +/** Loads the current public route row for a ranked popularity counter. */ +const loadCurrentTrendingRoute = Effect.fn( + "curriculumLessons.trending.loadCurrentTrendingRoute" +)(function* (ctx: QueryCtx, row: Doc<"learningPopularityCounters">) { + const route = yield* Effect.tryPromise({ + try: () => + ctx.db + .query("contentRoutes") + .withIndex("by_content_id", (q) => q.eq("content_id", row.content_id)) + .unique(), + catch: toTrendingSubjectIoError, + }); + + if ( + !( + route && + route.locale === row.locale && + route.kind === "curriculum-lesson" && + route.section === "material" && + route.content_id === route.assetId + ) + ) { + return; + } + + return route; }); /** Exposes public route fields while keeping internal sourcePath out of UI rows. */ -function toTrendingContentRef(route: Doc<"contentRoutes">) { +function toTrendingContentRef( + route: Parameters[0] +) { const ref = buildContentSearchRef(route); return { @@ -176,63 +151,66 @@ function toTrendingContentRef(route: Doc<"contentRoutes">) { }; } -/** Loads the graph route projection for one trending curriculum lesson. */ -const loadSubjectRoute = Effect.fn( - "curriculumLessons.trending.loadSubjectRoute" -)(function* (ctx: QueryCtx, contentId: Doc<"contentRoutes">["content_id"]) { - const route = yield* Effect.tryPromise({ - try: () => - ctx.db - .query("contentRoutes") - .withIndex("by_content_id", (q) => q.eq("content_id", contentId)) - .unique(), - catch: toTrendingSubjectIoError, - }); - - if ( - route?.kind !== "curriculum-lesson" || - route.content_id !== route.assetId - ) { - return null; +/** Projects a ranked popularity row to the public homepage card shape. */ +function toTrendingSubject( + row: Doc<"learningPopularityCounters">, + route: Doc<"contentRoutes"> +): TrendingSubject[] { + if (!route.materialDomain) { + return []; } - return route; -}); + return [ + { + ...toTrendingContentRef(route), + contextKey: row.contextKey, + description: route.description ?? "", + href: `/${cleanSlug(route.route)}${toLearningContextQuery(row)}`, + materialDomain: route.materialDomain, + title: route.title, + viewCount: row.score, + }, + ]; +} /** - * Lists trending subjects from the bounded daily bucket read model. + * Lists trending subjects from the ranked popularity read model. * - * The query scans only the locale/range index, aggregates counts in memory for - * the requested window, then batch-loads only the top subject documents. - * @see https://docs.convex.dev/understanding/best-practices/#only-use-collect-with-a-small-number-of-results - * @see https://docs.convex.dev/database/pagination - * @see https://effect.website/docs/error-management/expected-errors/ + * The query uses the Aggregate ranked index for section, locale, and approved + * window, then advances through bounded pages when stale rows are filtered. + * No raw event or bucket scan is performed at request time. + * @see https://docs.convex.dev/understanding/best-practices/ */ export const listTrendingSubjects = Effect.fn( "curriculumLessons.trending.listTrendingSubjects" )(function* (ctx: QueryCtx, args: GetTrendingSubjectsArgs) { - const window = getTrendingWindow(args); + const settings = getTrendingSettings(args); - if (!window) { + if (settings.limit === 0) { return []; } - if (window instanceof InvalidTrendingRangeError) { - return yield* Effect.fail(window); - } + const ids = yield* loadRankedPopularityCounterIds(ctx, args, settings); + const rows = yield* loadRankedPopularityCounters(ctx, ids); + const subjects: TrendingSubject[] = []; - const settings = getTrendingSettings(args); + for (const row of rows) { + if (row.score < settings.minViews) { + continue; + } - if (settings.limit === 0) { - return []; - } + const route = yield* loadCurrentTrendingRoute(ctx, row); - const countBySubject = yield* loadSubjectViewCounts(ctx, args, window); - const trendingEntries = getTopTrendingEntries(countBySubject, settings); + if (!route) { + continue; + } - if (trendingEntries.length === 0) { - return []; + subjects.push(...toTrendingSubject(row, route)); + + if (subjects.length >= settings.limit) { + break; + } } - return yield* buildTrendingSubjects(ctx, trendingEntries); + return subjects.slice(0, settings.limit); }); diff --git a/packages/backend/convex/curriculumLessons/trending/spec.ts b/packages/backend/convex/curriculumLessons/trending/spec.ts index f08b6a4539..ed511d2b7a 100644 --- a/packages/backend/convex/curriculumLessons/trending/spec.ts +++ b/packages/backend/convex/curriculumLessons/trending/spec.ts @@ -1,3 +1,4 @@ +import { learningPopularityWindowValues } from "@repo/backend/convex/contents/popularity"; import { localeValidator } from "@repo/backend/convex/lib/validators/contents"; import { type TrendingSubject, @@ -5,20 +6,21 @@ import { } from "@repo/backend/convex/lib/validators/trending"; import { vv } from "@repo/backend/convex/lib/validators/vv"; import { type Infer, v } from "convex/values"; +import { literals } from "convex-helpers/validators"; import { Schema } from "effect"; -export const invalidTrendingRangeCode = "INVALID_TRENDING_RANGE"; export const trendingSubjectIoFailedCode = "TRENDING_SUBJECT_IO_FAILED"; -export const maxTrendingRangeDays = 31; export const maxTrendingSubjectsLimit = 24; +const learningPopularityWindowValidator = literals( + ...learningPopularityWindowValues +); export const getTrendingSubjectsArgs = { locale: localeValidator, - since: vv.number(), - until: vv.number(), limit: vv.optional(vv.number()), minViews: vv.optional(vv.number()), + windowKey: vv.optional(learningPopularityWindowValidator), }; export const getTrendingSubjectsArgsValidator = v.object( @@ -35,15 +37,6 @@ export type GetTrendingSubjectsArgs = Infer< export type GetTrendingSubjectsResult = TrendingSubject[]; -/** Raised when a caller asks for an unbounded trending range. */ -export class InvalidTrendingRangeError extends Schema.TaggedError()( - "InvalidTrendingRangeError", - { - code: Schema.Literal(invalidTrendingRangeCode), - message: Schema.String, - } -) {} - /** Raised when Convex IO fails while reading trending subjects. */ export class TrendingSubjectIoError extends Schema.TaggedError()( "TrendingSubjectIoError", diff --git a/packages/backend/convex/curriculumLessons/utils.ts b/packages/backend/convex/curriculumLessons/utils.ts deleted file mode 100644 index 3e542729e4..0000000000 --- a/packages/backend/convex/curriculumLessons/utils.ts +++ /dev/null @@ -1,28 +0,0 @@ -export const TRENDING_BUCKET_MS = 24 * 60 * 60 * 1000; - -/** - * Floors one timestamp into the daily popularity bucket used by curriculum - * trending reads and writes. - */ -export function getTrendingBucketStart(timestamp: number) { - return Math.floor(timestamp / TRENDING_BUCKET_MS) * TRENDING_BUCKET_MS; -} - -/** - * Get day-bucketed timestamps for stable caching and bounded trending reads. - * - * @example - * const { since, until } = getTrendingTimeRange(7, nowMs); // Last 7 days - */ -export function getTrendingTimeRange( - days: number, - nowMs: number -): { - since: number; - until: number; -} { - const until = getTrendingBucketStart(nowMs) + TRENDING_BUCKET_MS; - const since = until - days * TRENDING_BUCKET_MS; - - return { since, until }; -} diff --git a/packages/backend/convex/functions.ts b/packages/backend/convex/functions.ts index 49fde01fff..245e0cb0df 100644 --- a/packages/backend/convex/functions.ts +++ b/packages/backend/convex/functions.ts @@ -18,7 +18,8 @@ import { commentsHandler } from "@repo/backend/convex/triggers/comments/comments import { commentVotesHandler } from "@repo/backend/convex/triggers/comments/commentVotes"; import { exerciseAnswersHandler } from "@repo/backend/convex/triggers/contents/exerciseAnswers"; import { exerciseAttemptsHandler } from "@repo/backend/convex/triggers/contents/exerciseAttempts"; -import { contentViewsHandler } from "@repo/backend/convex/triggers/contents/views"; +import { learningPopularityRankingsTrigger } from "@repo/backend/convex/triggers/contents/popularity"; +import { learningViewsHandler } from "@repo/backend/convex/triggers/contents/views"; import { postReactionsHandler } from "@repo/backend/convex/triggers/forums/postReactions"; import { forumPostsHandler } from "@repo/backend/convex/triggers/forums/posts"; import { forumReactionsHandler } from "@repo/backend/convex/triggers/forums/reactions"; @@ -53,7 +54,7 @@ export const internalMutation = customMutation( triggers.register("notifications", notificationsHandler); triggers.register("subscriptions", subscriptionsHandler); triggers.register("messages", messagesHandler); -triggers.register("contentViews", contentViewsHandler); +triggers.register("learningViews", learningViewsHandler); triggers.register("exerciseAttempts", exerciseAttemptsHandler); triggers.register("exerciseAnswers", exerciseAnswersHandler); triggers.register("comments", commentsHandler); @@ -69,6 +70,10 @@ triggers.register("schoolClassForumReactions", forumReactionsHandler); triggers.register("schoolClassMaterials", materialsHandler); triggers.register("schoolClassMaterialGroups", materialGroupsHandler); +triggers.register( + "learningPopularityCounters", + learningPopularityRankingsTrigger +); triggers.register("tryoutLeaderboardEntries", tryoutLeaderboardTrigger); triggers.register("userTryoutStats", globalLeaderboardTrigger); triggers.register("tryoutAttempts", tryoutAttemptsHandler); diff --git a/packages/backend/convex/lib/validators/trending.ts b/packages/backend/convex/lib/validators/trending.ts index d9809e18c9..a5edf19f0f 100644 --- a/packages/backend/convex/lib/validators/trending.ts +++ b/packages/backend/convex/lib/validators/trending.ts @@ -7,6 +7,8 @@ import { type Infer, v } from "convex/values"; */ export const trendingSubjectValidator = v.object({ ...contentSearchSummaryValidator.fields, + contextKey: v.string(), + href: v.string(), materialDomain: materialValidator, viewCount: v.number(), }); @@ -18,6 +20,8 @@ export type TrendingSubject = Infer; */ export const recentlyViewedSubjectValidator = v.object({ ...contentSearchSummaryValidator.fields, + contextKey: v.string(), + href: v.string(), lastViewedAt: v.number(), materialDomain: materialValidator, }); diff --git a/packages/backend/convex/test.helpers.ts b/packages/backend/convex/test.helpers.ts index 7b2d9f2e07..406e89553a 100644 --- a/packages/backend/convex/test.helpers.ts +++ b/packages/backend/convex/test.helpers.ts @@ -1,3 +1,4 @@ +import aggregateTest from "@convex-dev/aggregate/test"; import posthogTest from "@posthog/convex/test"; import { components } from "@repo/backend/convex/_generated/api"; import type { Doc } from "@repo/backend/convex/_generated/dataModel"; @@ -14,7 +15,7 @@ import schema from "@repo/backend/convex/schema"; import { convexModules } from "@repo/backend/convex/test.setup"; import aggregateSchema from "@repo/backend/node_modules/@convex-dev/aggregate/src/component/schema"; import { createLearningGraphIdentityFromRoute } from "@repo/contents/_types/learning-graph"; -import { convexTest } from "convex-test"; +import { convexTest, type TestConvex } from "convex-test"; const betterAuthModules = import.meta.glob(["./betterAuth/**/*.ts"]); const aggregateModules = import.meta.glob([ @@ -22,6 +23,16 @@ const aggregateModules = import.meta.glob([ ]); const DEFAULT_SESSION_DURATION_MS = 365 * 24 * 60 * 60 * 1000; +/** + * Registers the learning popularity ranking aggregate in tests that exercise + * ranked counter writes or queries without booting the full app deployment. + */ +export function registerLearningPopularityAggregate( + t: TestConvex +) { + aggregateTest.register(t, "learningPopularityRankings"); +} + /** Builds a Convex test instance with the Better Auth component registered. */ export function createConvexTestWithBetterAuth() { const t = convexTest(schema, convexModules); @@ -36,6 +47,7 @@ export function createConvexTestWithBetterAuth() { aggregateSchema, aggregateModules ); + registerLearningPopularityAggregate(t); posthogTest.register(t); return t; } diff --git a/packages/backend/convex/triggers/contents/popularity.ts b/packages/backend/convex/triggers/contents/popularity.ts new file mode 100644 index 0000000000..69b7e477c5 --- /dev/null +++ b/packages/backend/convex/triggers/contents/popularity.ts @@ -0,0 +1,8 @@ +import { learningPopularityRankings } from "@repo/backend/convex/contents/rankings"; + +/** + * Keeps the popularity ranking aggregate synchronized with every counter row + * write, so homepage top-N reads never fall back to table scans. + */ +export const learningPopularityRankingsTrigger = + learningPopularityRankings.trigger(); diff --git a/packages/backend/convex/triggers/contents/views.test.ts b/packages/backend/convex/triggers/contents/views.test.ts index 5005c1114c..ae6e73b229 100644 --- a/packages/backend/convex/triggers/contents/views.test.ts +++ b/packages/backend/convex/triggers/contents/views.test.ts @@ -97,6 +97,7 @@ describe("triggers/contents/views", () => { alignment_id: graph.alignmentId, concept_id: graph.conceptId, content_id: ARTICLE_CONTENT_ID, + context_key: "canonical", content_type: "article", is_new_view: true, learning_object_id: graph.learningObjectId, diff --git a/packages/backend/convex/triggers/contents/views.ts b/packages/backend/convex/triggers/contents/views.ts index 3477fede42..76abc36b3f 100644 --- a/packages/backend/convex/triggers/contents/views.ts +++ b/packages/backend/convex/triggers/contents/views.ts @@ -30,7 +30,7 @@ function toContentViewEventError(error: unknown) { /** Converts a graph route section into the product analytics event taxonomy. */ function getContentViewEventType( - section: NonNullable["newDoc"]>["section"] + section: NonNullable["newDoc"]>["section"] ): Extract< ProductAnalyticsEvent, { name: "content viewed" } @@ -51,7 +51,7 @@ const captureContentViewEvent = Effect.fn( "triggers.contents.captureContentViewEvent" )(function* ( ctx: GenericMutationCtx, - change: Change + change: Change ) { const view = change.newDoc; @@ -71,6 +71,7 @@ const captureContentViewEvent = Effect.fn( alignment_id: view.alignmentId, concept_id: view.conceptId, content_id: view.content_id, + context_key: view.contextKey, content_type: getContentViewEventType(view.section), is_new_view: change.operation === "insert", learning_object_id: view.learningObjectId, @@ -88,9 +89,9 @@ const captureContentViewEvent = Effect.fn( /** * Captures signed-in content views after the durable engagement row is written. */ -export async function contentViewsHandler( +export async function learningViewsHandler( ctx: GenericMutationCtx, - change: Change + change: Change ) { await runConvexProgram(captureContentViewEvent(ctx, change)); } diff --git a/packages/backend/scripts/sync-content/cleanup/analytics.test.ts b/packages/backend/scripts/sync-content/cleanup/analytics.test.ts index 0db536a9ef..47ff952109 100644 --- a/packages/backend/scripts/sync-content/cleanup/analytics.test.ts +++ b/packages/backend/scripts/sync-content/cleanup/analytics.test.ts @@ -43,13 +43,16 @@ const emptyCounts = { contentRoutes: 0, publicRoutes: 0, contentSearch: 0, - contentViewAnalyticsQueue: 0, - contentViews: 0, + learningEngagementQueue: 0, + learningViews: 0, learningProgramCoverage: 0, learningPlanItems: 0, learningProgramSources: 0, learningPrograms: 0, - learningPopularity: 0, + learningPopularityCounters: 0, + learningPopularitySignals: 0, + learningPopularityViewerSignals: 0, + userLearningRecents: 0, exerciseAnswers: 0, exerciseAttempts: 0, exerciseChoices: 0, @@ -76,7 +79,6 @@ const emptyCounts = { materials: 0, curriculumLessons: 0, curriculumTopics: 0, - learningTrendingBuckets: 0, tryoutAccessCampaignProducts: 0, tryoutAccessCampaigns: 0, tryoutAccessGrants: 0, @@ -98,12 +100,12 @@ describe("sync-content resetAnalytics", () => { it("points production dry runs at the analytics-only reset command", async () => { vi.mocked(getContentCounts).mockReturnValue( - Effect.succeed({ ...emptyCounts, contentViews: 20_000 }) + Effect.succeed({ ...emptyCounts, learningViews: 20_000 }) ); await Effect.runPromise(resetAnalytics(config, { prod: true })); - expect(log).toHaveBeenCalledWith(" Content Views: 20000"); + expect(log).toHaveBeenCalledWith(" Learning Views: 20000"); expect(log).toHaveBeenCalledWith( "\nTo delete content analytics rows, run:" ); @@ -117,11 +119,13 @@ describe("sync-content resetAnalytics", () => { vi.mocked(getContentCounts).mockReturnValue( Effect.succeed({ ...emptyCounts, - learningPopularity: 1, contentAnalyticsPartitions: 1, - contentViewAnalyticsQueue: 1, - contentViews: 2, - learningTrendingBuckets: 1, + learningEngagementQueue: 1, + learningPopularityCounters: 1, + learningPopularitySignals: 1, + learningPopularityViewerSignals: 1, + learningViews: 2, + userLearningRecents: 1, }) ); vi.mocked(callConvexMutation) @@ -129,26 +133,34 @@ describe("sync-content resetAnalytics", () => { .mockReturnValueOnce(Effect.succeed({ deleted: 1, hasMore: false })) .mockReturnValueOnce(Effect.succeed({ deleted: 2, hasMore: false })) .mockReturnValueOnce(Effect.succeed({ deleted: 1, hasMore: false })) + .mockReturnValueOnce(Effect.succeed({ deleted: 1, hasMore: false })) + .mockReturnValueOnce(Effect.succeed({ deleted: 1, hasMore: false })) .mockReturnValueOnce(Effect.succeed({ deleted: 1, hasMore: false })); await Effect.runPromise(resetAnalytics(config, { force: true })); - expect(callConvexMutation).toHaveBeenCalledTimes(5); + expect(callConvexMutation).toHaveBeenCalledTimes(7); expect(logSuccess).toHaveBeenCalledWith( - " Deleted 1 content view analytics queue rows" + " Deleted 1 learning engagement queue rows" ); expect(logSuccess).toHaveBeenCalledWith( " Deleted 1 content analytics partition leases" ); - expect(logSuccess).toHaveBeenCalledWith(" Deleted 2 content view rows"); + expect(logSuccess).toHaveBeenCalledWith(" Deleted 2 learning view rows"); + expect(logSuccess).toHaveBeenCalledWith( + " Deleted 1 user learning recents rows" + ); + expect(logSuccess).toHaveBeenCalledWith( + " Deleted 1 learning popularity signal rows" + ); expect(logSuccess).toHaveBeenCalledWith( - " Deleted 1 learning popularity rows" + " Deleted 1 learning popularity viewer signal rows" ); expect(logSuccess).toHaveBeenCalledWith( - " Deleted 1 learning trending bucket rows" + " Deleted 1 learning popularity counter rows" ); expect(logSuccess).toHaveBeenCalledWith( - "Deleted 6 analytics rows across content tables" + "Deleted 8 analytics rows across content tables" ); }); }); diff --git a/packages/backend/scripts/sync-content/cleanup/analytics.ts b/packages/backend/scripts/sync-content/cleanup/analytics.ts index bd2a66f84c..7527d9cbf8 100644 --- a/packages/backend/scripts/sync-content/cleanup/analytics.ts +++ b/packages/backend/scripts/sync-content/cleanup/analytics.ts @@ -31,10 +31,10 @@ interface ResetAnalyticsStep { const RESET_ANALYTICS_STEPS: ResetAnalyticsStep[] = [ { - label: "Deleting content view analytics queue...", + label: "Deleting learning engagement queue...", mutation: - internal.contentSync.reset.internal.deleteContentViewAnalyticsQueueBatch, - resultLabel: "content view analytics queue rows", + internal.contentSync.reset.internal.deleteLearningEngagementQueueBatch, + resultLabel: "learning engagement queue rows", }, { label: "Deleting content analytics partition leases...", @@ -43,20 +43,34 @@ const RESET_ANALYTICS_STEPS: ResetAnalyticsStep[] = [ resultLabel: "content analytics partition leases", }, { - label: "Deleting content view rows...", - mutation: internal.contentSync.reset.internal.deleteContentViewsBatch, - resultLabel: "content view rows", + label: "Deleting learning view rows...", + mutation: internal.contentSync.reset.internal.deleteLearningViewsBatch, + resultLabel: "learning view rows", }, { - label: "Deleting learning popularity rows...", - mutation: internal.contentSync.reset.internal.deleteLearningPopularityBatch, - resultLabel: "learning popularity rows", + label: "Deleting user learning recents rows...", + mutation: + internal.contentSync.reset.internal.deleteUserLearningRecentsBatch, + resultLabel: "user learning recents rows", + }, + { + label: "Deleting learning popularity signal rows...", + mutation: + internal.contentSync.reset.internal.deleteLearningPopularitySignalsBatch, + resultLabel: "learning popularity signal rows", + }, + { + label: "Deleting learning popularity viewer signal rows...", + mutation: + internal.contentSync.reset.internal + .deleteLearningPopularityViewerSignalsBatch, + resultLabel: "learning popularity viewer signal rows", }, { - label: "Deleting learning trending bucket rows...", + label: "Deleting learning popularity counter rows...", mutation: - internal.contentSync.reset.internal.deleteLearningTrendingBucketsBatch, - resultLabel: "learning trending bucket rows", + internal.contentSync.reset.internal.deleteLearningPopularityCountersBatch, + resultLabel: "learning popularity counter rows", }, ]; @@ -108,7 +122,7 @@ export const resetAnalytics = Effect.fn("sync.resetAnalytics")(function* ( ) { log("=== RESET CONTENT ANALYTICS ===\n"); log( - "This deletes content view history, analytics queue rows, popularity counts, trending buckets, and analytics partition leases." + "This deletes learning view history, analytics queue rows, popularity read models, and analytics partition leases." ); log( "New product traffic will repopulate these graph-backed analytics tables after strict code is deployed.\n" @@ -127,18 +141,22 @@ export const resetAnalytics = Effect.fn("sync.resetAnalytics")(function* ( const counts = yield* getContentCounts(config); const totalAnalyticsRows = - counts.contentViews + - counts.contentViewAnalyticsQueue + + counts.learningViews + + counts.learningEngagementQueue + counts.contentAnalyticsPartitions + - counts.learningPopularity + - counts.learningTrendingBuckets; + counts.userLearningRecents + + counts.learningPopularityViewerSignals + + counts.learningPopularitySignals + + counts.learningPopularityCounters; log("Current content analytics database contents:\n"); - log(` Content Views: ${counts.contentViews}`); - log(` Analytics Queue: ${counts.contentViewAnalyticsQueue}`); + log(` Learning Views: ${counts.learningViews}`); + log(` Engagement Queue: ${counts.learningEngagementQueue}`); log(` Analytics Partitions: ${counts.contentAnalyticsPartitions}`); - log(` Learning Popularity: ${counts.learningPopularity}`); - log(` Learning Trending: ${counts.learningTrendingBuckets}`); + log(` User Recents: ${counts.userLearningRecents}`); + log(` Viewer Signals: ${counts.learningPopularityViewerSignals}`); + log(` Popularity Signals: ${counts.learningPopularitySignals}`); + log(` Popularity Counters: ${counts.learningPopularityCounters}`); log(`\n Total analytics rows: ${totalAnalyticsRows}`); if (totalAnalyticsRows === 0) { diff --git a/packages/backend/scripts/sync-content/cleanup/audio.test.ts b/packages/backend/scripts/sync-content/cleanup/audio.test.ts index ff46e67d74..ac1b4149c4 100644 --- a/packages/backend/scripts/sync-content/cleanup/audio.test.ts +++ b/packages/backend/scripts/sync-content/cleanup/audio.test.ts @@ -47,13 +47,16 @@ const emptyCounts = { contentRoutes: 0, publicRoutes: 0, contentSearch: 0, - contentViewAnalyticsQueue: 0, - contentViews: 0, + learningEngagementQueue: 0, + learningViews: 0, learningProgramCoverage: 0, learningPlanItems: 0, learningProgramSources: 0, learningPrograms: 0, - learningPopularity: 0, + learningPopularityCounters: 0, + learningPopularitySignals: 0, + learningPopularityViewerSignals: 0, + userLearningRecents: 0, exerciseAnswers: 0, exerciseAttempts: 0, exerciseChoices: 0, @@ -79,7 +82,6 @@ const emptyCounts = { materialLocales: 0, materials: 0, curriculumLessons: 0, - learningTrendingBuckets: 0, curriculumTopics: 0, tryoutAccessCampaignProducts: 0, tryoutAccessCampaigns: 0, diff --git a/packages/backend/scripts/sync-content/cleanup/reset.test.ts b/packages/backend/scripts/sync-content/cleanup/reset.test.ts index ef7a15c75d..338256f623 100644 --- a/packages/backend/scripts/sync-content/cleanup/reset.test.ts +++ b/packages/backend/scripts/sync-content/cleanup/reset.test.ts @@ -51,13 +51,16 @@ const emptyCounts = { contentRoutes: 0, publicRoutes: 0, contentSearch: 0, - contentViewAnalyticsQueue: 0, - contentViews: 0, + learningEngagementQueue: 0, + learningViews: 0, learningProgramCoverage: 0, learningPlanItems: 0, learningProgramSources: 0, learningPrograms: 0, - learningPopularity: 0, + learningPopularityCounters: 0, + learningPopularitySignals: 0, + learningPopularityViewerSignals: 0, + userLearningRecents: 0, exerciseAnswers: 0, exerciseAttempts: 0, exerciseChoices: 0, @@ -83,7 +86,6 @@ const emptyCounts = { materialLocales: 0, materials: 0, curriculumLessons: 0, - learningTrendingBuckets: 0, curriculumTopics: 0, tryoutAccessCampaignProducts: 0, tryoutAccessCampaigns: 0, diff --git a/packages/backend/scripts/sync-content/cleanup/reset.ts b/packages/backend/scripts/sync-content/cleanup/reset.ts index 6c411a4b55..8a05864e3c 100644 --- a/packages/backend/scripts/sync-content/cleanup/reset.ts +++ b/packages/backend/scripts/sync-content/cleanup/reset.ts @@ -77,11 +77,13 @@ export const reset = Effect.fn("sync.reset")(function* ( const counts = yield* getContentCounts(config); log(` Content Search: ${counts.contentSearch}`); - log(` Content Views: ${counts.contentViews}`); - log(` View Analytics Queue: ${counts.contentViewAnalyticsQueue}`); + log(` Learning Views: ${counts.learningViews}`); + log(` Engagement Queue: ${counts.learningEngagementQueue}`); log(` Analytics Partitions: ${counts.contentAnalyticsPartitions}`); - log(` Learning Popularity: ${counts.learningPopularity}`); - log(` Learning Trending: ${counts.learningTrendingBuckets}`); + log(` User Recents: ${counts.userLearningRecents}`); + log(` Viewer Signals: ${counts.learningPopularityViewerSignals}`); + log(` Popularity Signals: ${counts.learningPopularitySignals}`); + log(` Popularity Counters: ${counts.learningPopularityCounters}`); log(` Materials: ${counts.materials}`); log(` Material Locales: ${counts.materialLocales}`); log(` Curricula: ${counts.curricula}`); @@ -174,11 +176,13 @@ export const reset = Effect.fn("sync.reset")(function* ( counts.irtScaleVersionItems; const totalDerived = counts.contentSearch + - counts.contentViews + - counts.contentViewAnalyticsQueue + + counts.learningViews + + counts.learningEngagementQueue + counts.contentAnalyticsPartitions + - counts.learningPopularity + - counts.learningTrendingBuckets + + counts.userLearningRecents + + counts.learningPopularityViewerSignals + + counts.learningPopularitySignals + + counts.learningPopularityCounters + counts.materials + counts.materialLocales + counts.curricula + diff --git a/packages/backend/scripts/sync-content/cleanup/steps.ts b/packages/backend/scripts/sync-content/cleanup/steps.ts index d912d8be3e..0a056f4d80 100644 --- a/packages/backend/scripts/sync-content/cleanup/steps.ts +++ b/packages/backend/scripts/sync-content/cleanup/steps.ts @@ -29,10 +29,10 @@ export const RESET_STEPS: ResetStep[] = [ resultLabel: "content search rows", }, { - label: "Deleting content view analytics queue...", + label: "Deleting learning engagement queue...", mutation: - internal.contentSync.reset.internal.deleteContentViewAnalyticsQueueBatch, - resultLabel: "content view analytics queue rows", + internal.contentSync.reset.internal.deleteLearningEngagementQueueBatch, + resultLabel: "learning engagement queue rows", }, { label: "Deleting content analytics partition leases...", @@ -41,20 +41,34 @@ export const RESET_STEPS: ResetStep[] = [ resultLabel: "content analytics partition leases", }, { - label: "Deleting content view rows...", - mutation: internal.contentSync.reset.internal.deleteContentViewsBatch, - resultLabel: "content view rows", + label: "Deleting learning view rows...", + mutation: internal.contentSync.reset.internal.deleteLearningViewsBatch, + resultLabel: "learning view rows", }, { - label: "Deleting learning popularity rows...", - mutation: internal.contentSync.reset.internal.deleteLearningPopularityBatch, - resultLabel: "learning popularity rows", + label: "Deleting user learning recents rows...", + mutation: + internal.contentSync.reset.internal.deleteUserLearningRecentsBatch, + resultLabel: "user learning recents rows", + }, + { + label: "Deleting learning popularity signal rows...", + mutation: + internal.contentSync.reset.internal.deleteLearningPopularitySignalsBatch, + resultLabel: "learning popularity signal rows", + }, + { + label: "Deleting learning popularity viewer signal rows...", + mutation: + internal.contentSync.reset.internal + .deleteLearningPopularityViewerSignalsBatch, + resultLabel: "learning popularity viewer signal rows", }, { - label: "Deleting learning trending bucket rows...", + label: "Deleting learning popularity counter rows...", mutation: - internal.contentSync.reset.internal.deleteLearningTrendingBucketsBatch, - resultLabel: "learning trending bucket rows", + internal.contentSync.reset.internal.deleteLearningPopularityCountersBatch, + resultLabel: "learning popularity counter rows", }, { label: "Deleting generated assessment nodes...", diff --git a/packages/backend/scripts/sync-content/contract/schemas.ts b/packages/backend/scripts/sync-content/contract/schemas.ts index 0062e91026..247d0835a3 100644 --- a/packages/backend/scripts/sync-content/contract/schemas.ts +++ b/packages/backend/scripts/sync-content/contract/schemas.ts @@ -385,6 +385,7 @@ export const AuthorSyncResultSchema = Schema.Struct({ existing: Schema.Number, }); +/** Counts every read-model table inspected by cleanup and import verification. */ export const ContentCountsSchema = Schema.Struct({ articleReferences: Schema.Number, articles: Schema.Number, @@ -399,8 +400,8 @@ export const ContentCountsSchema = Schema.Struct({ contentRoutes: Schema.Number, publicRoutes: Schema.Number, contentSearch: Schema.Number, - contentViewAnalyticsQueue: Schema.Number, - contentViews: Schema.Number, + learningEngagementQueue: Schema.Number, + learningViews: Schema.Number, exerciseAnswers: Schema.Number, exerciseAttempts: Schema.Number, exerciseChoices: Schema.Number, @@ -425,13 +426,15 @@ export const ContentCountsSchema = Schema.Struct({ learningPlanItems: Schema.Number, learningProgramSources: Schema.Number, learningPrograms: Schema.Number, - learningPopularity: Schema.Number, + learningPopularityCounters: Schema.Number, + learningPopularitySignals: Schema.Number, + learningPopularityViewerSignals: Schema.Number, materialLocales: Schema.Number, materials: Schema.Number, quranSurahs: Schema.Number, quranVerses: Schema.Number, curriculumLessons: Schema.Number, - learningTrendingBuckets: Schema.Number, + userLearningRecents: Schema.Number, curriculumTopics: Schema.Number, tryoutAccessCampaignProducts: Schema.Number, tryoutAccessCampaigns: Schema.Number, diff --git a/packages/backend/scripts/sync-content/convex/inspection.ts b/packages/backend/scripts/sync-content/convex/inspection.ts index 3f32051ef5..50face7842 100644 --- a/packages/backend/scripts/sync-content/convex/inspection.ts +++ b/packages/backend/scripts/sync-content/convex/inspection.ts @@ -35,10 +35,12 @@ export const GRAPH_IDENTITY_TARGETS = [ "contentSearch", "contentRoutePages", "parts", - "contentViews", - "contentViewAnalyticsQueue", - "learningPopularity", - "learningTrendingBuckets", + "learningViews", + "learningEngagementQueue", + "userLearningRecents", + "learningPopularityViewerSignals", + "learningPopularitySignals", + "learningPopularityCounters", "audioContentSources", "audioGenerationQueue", "contentAudios", diff --git a/packages/backend/scripts/sync-content/models/rows.ts b/packages/backend/scripts/sync-content/models/rows.ts index 27ae83b17d..8f0ad40ede 100644 --- a/packages/backend/scripts/sync-content/models/rows.ts +++ b/packages/backend/scripts/sync-content/models/rows.ts @@ -319,7 +319,14 @@ function toPublicRoutePayload(route: PublicRoute): PublicRoutePayload { "displayGroupTitle" in route ? route.displayGroupTitle : undefined, iconKey: "iconKey" in route ? route.iconKey : undefined, kind: route.kind, + level: "level" in route ? route.level : undefined, locale: route.locale, + materialCardDescription: + "materialCardDescription" in route + ? route.materialCardDescription + : undefined, + materialCardTitle: + "materialCardTitle" in route ? route.materialCardTitle : undefined, materialDomain: "materialDomain" in route ? route.materialDomain : undefined, materialKey: "materialKey" in route ? route.materialKey : undefined, diff --git a/packages/contents/_types/route/content.ts b/packages/contents/_types/route/content.ts index bf579fd368..8da95d6943 100644 --- a/packages/contents/_types/route/content.ts +++ b/packages/contents/_types/route/content.ts @@ -230,7 +230,10 @@ export function readParentMaterialRoute( */ export function readMaterialPagination( route: PublicContentRoute, - routes: readonly PublicContentRoute[] + routes: readonly PublicContentRoute[], + options: { + readonly toHref?: (route: PublicContentRoute) => string; + } = {} ): ContentPagination { const emptyItem = { href: "", title: "" }; @@ -257,12 +260,14 @@ export function readMaterialPagination( return { prev: readPaginationItem( - currentIndex > 0 ? siblings[currentIndex - 1] : undefined + currentIndex > 0 ? siblings[currentIndex - 1] : undefined, + options ), next: readPaginationItem( currentIndex < siblings.length - 1 ? siblings[currentIndex + 1] - : undefined + : undefined, + options ), }; } @@ -369,13 +374,18 @@ function listPracticePublicRoutes( } /** Adapts an optional sibling lesson route to the established pagination item contract. */ -function readPaginationItem(route: PublicContentRoute | undefined) { +function readPaginationItem( + route: PublicContentRoute | undefined, + options: { + readonly toHref?: (route: PublicContentRoute) => string; + } +) { if (!route) { return { href: "", title: "" }; } return { - href: toLocalizedContentHref(route), + href: options.toHref?.(route) ?? toLocalizedContentHref(route), title: route.title, }; } diff --git a/packages/contents/_types/route/learning/public.test.ts b/packages/contents/_types/route/learning/public.test.ts index 8df4bc5346..1e08ddc149 100644 --- a/packages/contents/_types/route/learning/public.test.ts +++ b/packages/contents/_types/route/learning/public.test.ts @@ -231,6 +231,41 @@ describe("createPublicLearningIndex", () => { ).toBeUndefined(); }); + it("adds material context queries only to validated target routes", () => { + const index = createPublicLearningIndex({ routes: readStaticRoutes() }); + const route = requireMaterialLessonRoute( + index.resolveRouteByPath( + "materi/matematika/eksponen-dan-logaritma/konsep-eksponen", + "id" + ) + ); + const staleRoute = requireMaterialLessonRoute( + index.resolveRouteByPath("materi/fisika/vektor/konsep-vektor", "id") + ); + const context = { + nodeKey: "class-10-mathematics-exponential-logarithm", + programKey: "merdeka", + }; + + expect( + index.toContextualMaterialHref({ + context, + href: "/id/materi/matematika/eksponen-dan-logaritma/konsep-eksponen", + route, + }) + ).toBe( + "/id/materi/matematika/eksponen-dan-logaritma/konsep-eksponen?ctx=merdeka~class-10-mathematics-exponential-logarithm" + ); + + expect( + index.toContextualMaterialHref({ + context, + href: "/id/materi/fisika/vektor/konsep-vektor", + route: staleRoute, + }) + ).toBe("/id/materi/fisika/vektor/konsep-vektor"); + }); + it("keeps lookup results stable after callers release the source route array", () => { const routes = readStaticRoutes(); const index = createPublicLearningIndex({ routes }); diff --git a/packages/contents/_types/route/learning/public.ts b/packages/contents/_types/route/learning/public.ts index 009696619e..c48b13e310 100644 --- a/packages/contents/_types/route/learning/public.ts +++ b/packages/contents/_types/route/learning/public.ts @@ -29,52 +29,6 @@ import type { PublicRoute, } from "@repo/contents/_types/route/schema"; -interface PublicLearningIndexInput { - domains?: NonNullable; - materials?: NonNullable; - routes: readonly PublicRoute[]; -} - -/** - * Route-owned lookup Interface for public learning navigation. - * - * Callers ask source-identity questions instead of scanning all projected - * routes, reconstructing practice root/domain routes, or parsing material - * context query state themselves. - */ -export interface PublicLearningIndex { - /** Projects a valid material `ctx` hint from one locale route to another. */ - projectMaterialContextToLocale(input: { - context: MaterialContextIdentity | undefined; - currentRoute: MaterialRouteIdentity; - targetRoute: MaterialRouteIdentity; - }): MaterialContextIdentity | undefined; - /** Projects a virtual practice domain page that has no persisted route row. */ - projectPracticeDomainPath(input: { - currentLocale: Locale; - path: string; - targetLocale: Locale; - }): string | undefined; - /** Projects a virtual practice program root that has no persisted route row. */ - projectPracticeRootPath(input: { - currentLocale: Locale; - path: string; - targetLocale: Locale; - }): string | undefined; - /** Projects a persisted route row through stable source identity. */ - projectRouteToLocale( - route: PublicRoute, - locale: Locale - ): PublicRoute | undefined; - /** Resolves the contextual material header return link, if the hint is valid. */ - resolveMaterialHeaderLink(input: { - context: MaterialContextIdentity | undefined; - route: MaterialRouteIdentity; - }): { href: string; label: string } | undefined; - /** Resolves a localized public path through exact and virtual route indexes. */ - resolveRouteByPath(path: string, locale: Locale): PublicRoute | undefined; -} - /** * Builds keyed route/context maps from already-decoded public route rows. * @@ -85,7 +39,11 @@ export function createPublicLearningIndex({ domains, materials, routes, -}: PublicLearningIndexInput): PublicLearningIndex { +}: { + domains?: NonNullable; + materials?: NonNullable; + routes: readonly PublicRoute[]; +}) { const routesByPath = new Map(); const routesByIdentityAndLocale = new Map(); const routeDomains = domains ?? MATERIAL_ROUTE_DOMAINS; @@ -175,6 +133,19 @@ export function createPublicLearningIndex({ return materialContextIndex.resolveHeaderLink(input); } + /** Adds a material context query only when the target route validates it. */ + function toContextualMaterialHref(input: { + context: MaterialContextIdentity; + href: string; + route: MaterialRouteIdentity; + }) { + return materialContextIndex.toContextualHref({ + contextRoute: input.context, + href: input.href, + route: input.route, + }); + } + return { projectMaterialContextToLocale, projectPracticeDomainPath, @@ -182,5 +153,14 @@ export function createPublicLearningIndex({ projectRouteToLocale, resolveMaterialHeaderLink, resolveRouteByPath, + toContextualMaterialHref, }; } + +/** + * Route-owned lookup Interface derived from `createPublicLearningIndex`. + * + * Callers ask source-identity questions instead of scanning all projected + * routes, reconstructing practice roots, or parsing material context state. + */ +export type PublicLearningIndex = ReturnType;