diff --git a/.github/workflows/accept_release_candidate.yml b/.github/workflows/accept_release_candidate.yml new file mode 100644 index 000000000..0077d4afc --- /dev/null +++ b/.github/workflows/accept_release_candidate.yml @@ -0,0 +1,27 @@ +name: Accept Release Candidate + +on: + pull_request_review: + types: [submitted] + +jobs: + merge: + name: Merge Release Candidate + if: | + github.event.review.state == 'approved' && + github.event.pull_request.merged == false && + github.event.pull_request.base.ref == 'production' && + startsWith(github.event.pull_request.head.ref, 'rc') + + runs-on: ubuntu-latest + steps: + - name: "Merge pull request" + uses: "actions/github-script@v7" + with: + script: | + await github.rest.pulls.merge({ + merge_method: "merge", + owner: context.repo.owner, + pull_number: context.issue.number, + repo: context.repo.repo, + }) diff --git a/CHANGE_LOG.md b/CHANGE_LOG.md index d70ba9af3..e8058b80d 100644 --- a/CHANGE_LOG.md +++ b/CHANGE_LOG.md @@ -1,3 +1,10 @@ +## [1.6.1](https://github.com/oaknational/oak-ai-lesson-assistant/compare/v1.6.0...v1.6.1) (2024-09-12) + + +### Bug Fixes + +* survey not found bug ([#121](https://github.com/oaknational/oak-ai-lesson-assistant/issues/121)) ([e22cfa5](https://github.com/oaknational/oak-ai-lesson-assistant/commit/e22cfa5cf15765fec20928aedb806645dd32895f)) + # [1.6.0](https://github.com/oaknational/oak-ai-lesson-assistant/compare/v1.5.1...v1.6.0) (2024-09-06) diff --git a/apps/nextjs/.eslintrc.cjs b/apps/nextjs/.eslintrc.cjs index 1d2fce424..cc5073297 100644 --- a/apps/nextjs/.eslintrc.cjs +++ b/apps/nextjs/.eslintrc.cjs @@ -1,4 +1,19 @@ /** @type {import("eslint").Linter.Config} */ module.exports = { extends: ["../../.eslintrc.cjs", "next", "plugin:storybook/recommended"], + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "posthog-js/react", + importNames: ["usePostHog"], + message: + "usePostHog doesn't support multiple PostHog instances, use useAnalytics instead", + }, + ], + }, + ], + }, }; diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index effbde15a..99e9ba726 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -80,7 +80,7 @@ "languagedetect": "^2.0.0", "next": "14.2.5", "object-hash": "^3.0.0", - "openai": "^4.52.0", + "openai": "^4.58.1", "p-limit": "^6.1.0", "partial-json-parser": "^1.2.2", "posthog-js": "^1.139.1", diff --git a/apps/nextjs/src/app/actions.ts b/apps/nextjs/src/app/actions.ts index b22900c06..d515d3330 100644 --- a/apps/nextjs/src/app/actions.ts +++ b/apps/nextjs/src/app/actions.ts @@ -1,6 +1,5 @@ "use server"; -import { auth } from "@clerk/nextjs/server"; import { AilaPersistedChat, chatSchema } from "@oakai/aila/src/protocol/schema"; import { Prisma, prisma } from "@oakai/db"; import * as Sentry from "@sentry/nextjs"; @@ -58,26 +57,6 @@ export async function getChatById( ); } -export async function getChatForAuthenticatedUser( - id: string, -): Promise { - const { userId } = auth(); - - const chat = await getChatById(id); - - if (!chat) { - return null; - } - - const userIsOwner = chat.userId === userId; - - if (!userIsOwner) { - return null; - } - - return chat; -} - export async function getSharedChatById( id: string, ): Promise { diff --git a/apps/nextjs/src/app/admin/aila/[chatId]/layout.tsx b/apps/nextjs/src/app/admin/aila/[chatId]/layout.tsx new file mode 100644 index 000000000..fca46e5c0 --- /dev/null +++ b/apps/nextjs/src/app/admin/aila/[chatId]/layout.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { OakMaxWidth } from "@oaknational/oak-components"; + +import Layout from "@/components/AppComponents/Layout"; + +interface ChatLayoutProps { + children: React.ReactNode; +} + +export default function ChatLayout({ children }: Readonly) { + return ( +
+
+ +
+ + {children} + +
+
+
+
+ ); +} diff --git a/apps/nextjs/src/app/admin/aila/[chatId]/page.tsx b/apps/nextjs/src/app/admin/aila/[chatId]/page.tsx new file mode 100644 index 000000000..4203abf4e --- /dev/null +++ b/apps/nextjs/src/app/admin/aila/[chatId]/page.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useUser } from "#clerk/nextjs"; +import { redirect } from "#next/navigation"; + +import LoadingWheel from "@/components/LoadingWheel"; +import { trpc } from "@/utils/trpc"; + +import { AdminChatView } from "./view"; + +interface AdminChatProps { + params: { + chatId: string; + }; +} + +export default function AdminChat({ params }: Readonly) { + const user = useUser(); + const { chatId } = params; + const { data: chat, isLoading: isChatLoading } = trpc.admin.getChat.useQuery({ + id: chatId, + }); + const { data: moderations, isLoading: isModerationsLoading } = + trpc.admin.getModerations.useQuery({ id: chatId }); + + if (user.isLoaded && !user.isSignedIn) { + redirect(`/sign-in?next=/admin/aila/${params.chatId}`); + } + + if (isChatLoading || isModerationsLoading) { + return ; + } + + console.log("chat", chat); + + if (!chat) { + return
No chat found
; + } + + if (!moderations) { + return
No moderations found
; + } + + return ; +} diff --git a/apps/nextjs/src/app/admin/aila/[chatId]/view.tsx b/apps/nextjs/src/app/admin/aila/[chatId]/view.tsx new file mode 100644 index 000000000..8b223ea2e --- /dev/null +++ b/apps/nextjs/src/app/admin/aila/[chatId]/view.tsx @@ -0,0 +1,91 @@ +import { useState } from "react"; + +import { type AilaPersistedChat } from "@oakai/aila/src/protocol/schema"; +import { getSafetyResult } from "@oakai/core/src/utils/ailaModeration/helpers"; +import { type Moderation } from "@oakai/db"; +import { OakAccordion, OakPrimaryButton } from "@oaknational/oak-components"; + +import { trpc } from "@/utils/trpc"; + +function ModerationListItem({ moderation }: { moderation: Moderation }) { + const { id, invalidatedAt } = moderation; + const [invalidated, setInvalidated] = useState(Boolean(invalidatedAt)); + const invalidateModeration = trpc.admin.invalidateModeration.useMutation({ + onSuccess: () => setInvalidated(true), + }); + return ( +
  • +
    +
    +
    +

    + {getSafetyResult(moderation)} +

    + + invalidateModeration.mutateAsync({ moderationId: id }) + } + isLoading={invalidateModeration.isLoading} + disabled={!!invalidated} + > + {invalidated ? "Invalidated" : "Invalidate"} + +
    + +
    + {moderation.justification} +
    +
    + {moderation.categories.map((category, index) => ( + + {String(category)} + + ))} +
    +
    +
    +
  • + ); +} + +export function AdminChatView({ + chat, + moderations, +}: { + chat: AilaPersistedChat; + moderations: Moderation[]; +}) { + return ( + <> +

    {chat.lessonPlan.title}

    +

    Moderations

    +
      + {moderations.map((moderation) => { + return ( + + ); + })} +
    + +

    Raw data

    + +
    +          {JSON.stringify(chat, null, 2)}
    +        
    +
    + +
    +          {JSON.stringify(moderations, null, 2)}
    +        
    +
    + + ); +} diff --git a/apps/nextjs/src/app/aila/help/index.tsx b/apps/nextjs/src/app/aila/help/index.tsx index 03f8bffa0..a58146343 100644 --- a/apps/nextjs/src/app/aila/help/index.tsx +++ b/apps/nextjs/src/app/aila/help/index.tsx @@ -3,6 +3,7 @@ import { useRef } from "react"; import { OakLink } from "@oaknational/oak-components"; +import { useSearchParams } from "next/navigation"; import { Header } from "@/components/AppComponents/Chat/header"; import GetInTouchBox from "@/components/AppComponents/GetInTouchBox"; @@ -25,6 +26,9 @@ const Help = () => { } }; + const searchParams = useSearchParams(); + const ailaId = searchParams.get("ailaId"); + return ( <>
    @@ -94,7 +98,10 @@ const Help = () => {
    - + Back to Aila
    diff --git a/apps/nextjs/src/app/api/chat/chatHandler.ts b/apps/nextjs/src/app/api/chat/chatHandler.ts index 66c006a53..a08d9b830 100644 --- a/apps/nextjs/src/app/api/chat/chatHandler.ts +++ b/apps/nextjs/src/app/api/chat/chatHandler.ts @@ -1,11 +1,6 @@ -import { - Aila, - AilaAuthenticationError, - AilaThreatDetectionError, -} from "@oakai/aila"; +import { Aila } from "@oakai/aila"; import type { AilaOptions, AilaPublicChatOptions, Message } from "@oakai/aila"; import { LooseLessonPlan } from "@oakai/aila/src/protocol/schema"; -import { handleHeliconeError } from "@oakai/aila/src/utils/moderation/moderationErrorHandling"; import { TracingSpan, withTelemetry, @@ -16,7 +11,8 @@ import { NextRequest } from "next/server"; import invariant from "tiny-invariant"; import { Config } from "./config"; -import { streamingJSON } from "./protocol"; +import { handleChatException } from "./errorHandling"; +import { fetchAndCheckUser } from "./user"; export const maxDuration = 300; @@ -61,25 +57,6 @@ async function setupChatHandler(req: NextRequest) { ); } -function reportErrorTelemetry( - span: TracingSpan, - error: Error, - errorType: string, - statusMessage: string, - additionalAttributes: Record< - string, - string | number | boolean | undefined - > = {}, -) { - span.setTag("error", true); - span.setTag("error.type", errorType); - span.setTag("error.message", statusMessage); - span.setTag("error.stack", error.stack); - Object.entries(additionalAttributes).forEach(([key, value]) => { - span.setTag(key, value); - }); -} - function setTelemetryMetadata( span: TracingSpan, id: string, @@ -110,65 +87,6 @@ function handleConnectionAborted(req: NextRequest) { return abortController; } -async function handleThreatDetectionError( - span: TracingSpan, - e: AilaThreatDetectionError, - userId: string, - id: string, - prisma: PrismaClientWithAccelerate, -) { - const heliconeErrorMessage = await handleHeliconeError(userId, id, e, prisma); - reportErrorTelemetry(span, e, "AilaThreatDetectionError", "Threat detected"); - return streamingJSON(heliconeErrorMessage); -} - -async function handleAilaAuthenticationError( - span: TracingSpan, - e: AilaAuthenticationError, -) { - reportErrorTelemetry(span, e, "AilaAuthenticationError", "Unauthorized"); - return new Response("Unauthorized", { status: 401 }); -} - -async function handleGenericError(span: TracingSpan, e: Error) { - reportErrorTelemetry(span, e, e.name, e.message); - return streamingJSON({ - type: "error", - message: e.message, - value: `Sorry, an error occurred: ${e.message}`, - }); -} - -async function getUserId(config: Config, chatId: string): Promise { - return await withTelemetry( - "chat-get-user-id", - { chat_id: chatId }, - async (userIdSpan: TracingSpan) => { - if (config.shouldPerformUserLookup) { - const userLookup = await config.handleUserLookup(chatId); - userIdSpan.setTag("user.lookup.performed", true); - - if ("failureResponse" in userLookup) { - if (userLookup.failureResponse) { - throw new Error("User lookup failed: failureResponse received"); - } - } - - if ("userId" in userLookup) { - userIdSpan.setTag("user_id", userLookup.userId); - return userLookup.userId; - } - - throw new Error("User lookup failed: userId not found"); - } - invariant(config.mockUserId, "User ID is required"); - userIdSpan.setTag("user_id", config.mockUserId); - userIdSpan.setTag("user.mock", true); - return config.mockUserId; - }, - ); -} - async function generateChatStream( aila: Aila, abortController: AbortController, @@ -184,28 +102,6 @@ async function generateChatStream( ); } -async function handleChatException( - span: TracingSpan, - e: unknown, - userId: string | undefined, - chatId: string, - prisma: PrismaClientWithAccelerate, -): Promise { - if (e instanceof AilaAuthenticationError) { - return handleAilaAuthenticationError(span, e); - } - - if (e instanceof AilaThreatDetectionError && userId) { - return handleThreatDetectionError(span, e, userId, chatId, prisma); - } - - if (e instanceof Error) { - return handleGenericError(span, e); - } - - throw e; -} - export async function handleChatPostRequest( req: NextRequest, config: Config, @@ -220,7 +116,8 @@ export async function handleChatPostRequest( let aila: Aila | undefined; try { - userId = await getUserId(config, chatId); + userId = await fetchAndCheckUser(chatId); + span.setTag("user_id", userId); aila = await withTelemetry( "chat-create-aila", @@ -244,7 +141,7 @@ export async function handleChatPostRequest( const stream = await generateChatStream(aila, abortController); return new StreamingTextResponse(stream); } catch (e) { - return handleChatException(span, e, userId, chatId, prisma); + return handleChatException(span, e, chatId, prisma); } finally { if (aila) { await aila.ensureShutdown(); diff --git a/apps/nextjs/src/app/api/chat/config.ts b/apps/nextjs/src/app/api/chat/config.ts index 74847945c..5c9b91f3f 100644 --- a/apps/nextjs/src/app/api/chat/config.ts +++ b/apps/nextjs/src/app/api/chat/config.ts @@ -5,27 +5,14 @@ import { } from "@oakai/db"; import { nanoid } from "ai"; -import { handleUserLookup as defaultHandleUserLookup } from "./user"; import { createWebActionsPlugin } from "./webActionsPlugin"; export interface Config { - shouldPerformUserLookup: boolean; - mockUserId?: string; - handleUserLookup: (chatId: string) => Promise< - | { - userId: string; - } - | { - failureResponse: Response; - } - >; prisma: PrismaClientWithAccelerate; createAila: (options: Partial) => Promise; } export const defaultConfig: Config = { - shouldPerformUserLookup: true, - handleUserLookup: defaultHandleUserLookup, prisma: globalPrisma, createAila: async (options) => { const webActionsPlugin = createWebActionsPlugin(globalPrisma); diff --git a/apps/nextjs/src/app/api/chat/errorHandling.test.ts b/apps/nextjs/src/app/api/chat/errorHandling.test.ts new file mode 100644 index 000000000..573c8550c --- /dev/null +++ b/apps/nextjs/src/app/api/chat/errorHandling.test.ts @@ -0,0 +1,126 @@ +import { AilaAuthenticationError, AilaThreatDetectionError } from "@oakai/aila"; +import * as moderationErrorHandling from "@oakai/aila/src/utils/moderation/moderationErrorHandling"; +import { UserBannedError } from "@oakai/core/src/models/safetyViolations"; +import { TracingSpan } from "@oakai/core/src/tracing/serverTracing"; +import { RateLimitExceededError } from "@oakai/core/src/utils/rateLimiting/userBasedRateLimiter"; +import { PrismaClientWithAccelerate } from "@oakai/db"; +import invariant from "tiny-invariant"; + +import { + consumeStream, + extractStreamMessage, +} from "@/utils/testHelpers/consumeStream"; + +import { handleChatException } from "./errorHandling"; + +describe("handleChatException", () => { + describe("AilaThreatDetectionError", () => { + it("should forward the message from handleHeliconeError", async () => { + jest + .spyOn(moderationErrorHandling, "handleHeliconeError") + .mockResolvedValue({ + type: "error", + value: "Threat detected", + message: "Threat was detected", + }); + + const span = { setTag: jest.fn() } as unknown as TracingSpan; + const error = new AilaThreatDetectionError("user_abc", "test error"); + const prisma = {} as unknown as PrismaClientWithAccelerate; + + const response = await handleChatException( + span, + error, + "test-chat-id", + prisma, + ); + + expect(response.status).toBe(200); + + invariant(response.body instanceof ReadableStream); + const message = extractStreamMessage(await consumeStream(response.body)); + + expect(message).toEqual({ + type: "error", + value: "Threat detected", + message: "Threat was detected", + }); + }); + }); + + describe("AilaAuthenticationError", () => { + it("should return an error chat message", async () => { + const span = { setTag: jest.fn() } as unknown as TracingSpan; + const error = new AilaAuthenticationError("test error"); + const prisma = {} as unknown as PrismaClientWithAccelerate; + + const response = await handleChatException( + span, + error, + "test-chat-id", + prisma, + ); + + expect(response.status).toBe(401); + + const message = await consumeStream(response.body as ReadableStream); + expect(message).toEqual("Unauthorized"); + }); + }); + + describe("RateLimitExceededError", () => { + it("should return an error chat message", async () => { + const span = { setTag: jest.fn() } as unknown as TracingSpan; + const error = new RateLimitExceededError( + "user_abc", + 100, + Date.now() + 3600 * 1000, + ); + const prisma = {} as unknown as PrismaClientWithAccelerate; + + const response = await handleChatException( + span, + error, + "test-chat-id", + prisma, + ); + + expect(response.status).toBe(200); + + const consumed = await consumeStream(response.body as ReadableStream); + const message = extractStreamMessage(consumed); + + expect(message).toEqual({ + type: "error", + value: "Rate limit exceeded", + message: + "**Unfortunately you’ve exceeded your fair usage limit for today.** Please come back in 1 hour. If you require a higher limit, please [make a request](https://forms.gle/tHsYMZJR367zydsG8).", + }); + }); + }); + + describe("UserBannedError", () => { + it("should return an error chat message", async () => { + const span = { setTag: jest.fn() } as unknown as TracingSpan; + const error = new UserBannedError("test error"); + const prisma = {} as unknown as PrismaClientWithAccelerate; + + const response = await handleChatException( + span, + error, + "test-chat-id", + prisma, + ); + + expect(response.status).toBe(200); + + const message = extractStreamMessage( + await consumeStream(response.body as ReadableStream), + ); + expect(message).toEqual({ + type: "action", + action: "SHOW_ACCOUNT_LOCKED", + }); + }); + }); +}); \ No newline at end of file diff --git a/apps/nextjs/src/app/api/chat/errorHandling.ts b/apps/nextjs/src/app/api/chat/errorHandling.ts new file mode 100644 index 000000000..be22e99d6 --- /dev/null +++ b/apps/nextjs/src/app/api/chat/errorHandling.ts @@ -0,0 +1,118 @@ +import { AilaAuthenticationError, AilaThreatDetectionError } from "@oakai/aila"; +import { + ActionDocument, + ErrorDocument, +} from "@oakai/aila/src/protocol/jsonPatchProtocol"; +import { handleHeliconeError } from "@oakai/aila/src/utils/moderation/moderationErrorHandling"; +import { UserBannedError } from "@oakai/core/src/models/safetyViolations"; +import { TracingSpan } from "@oakai/core/src/tracing/serverTracing"; +import { RateLimitExceededError } from "@oakai/core/src/utils/rateLimiting/userBasedRateLimiter"; +import { PrismaClientWithAccelerate } from "@oakai/db"; + +import { streamingJSON } from "./protocol"; + +function reportErrorTelemetry( + span: TracingSpan, + error: Error, + errorType: string, + statusMessage: string, + additionalAttributes: Record< + string, + string | number | boolean | undefined + > = {}, +) { + span.setTag("error", true); + span.setTag("error.type", errorType); + span.setTag("error.message", statusMessage); + span.setTag("error.stack", error.stack); + Object.entries(additionalAttributes).forEach(([key, value]) => { + span.setTag(key, value); + }); +} + +async function handleThreatDetectionError( + span: TracingSpan, + e: AilaThreatDetectionError, + id: string, + prisma: PrismaClientWithAccelerate, +) { + const heliconeErrorMessage = await handleHeliconeError( + e.userId, + id, + e, + prisma, + ); + reportErrorTelemetry(span, e, "AilaThreatDetectionError", "Threat detected"); + return streamingJSON(heliconeErrorMessage); +} + +async function handleAilaAuthenticationError( + span: TracingSpan, + e: AilaAuthenticationError, +) { + reportErrorTelemetry(span, e, "AilaAuthenticationError", "Unauthorized"); + return new Response("Unauthorized", { status: 401 }); +} + +export async function handleRateLimitError( + span: TracingSpan, + error: RateLimitExceededError, +) { + reportErrorTelemetry(span, error, "RateLimitExceededError", "Rate limited"); + + const timeRemainingHours = Math.ceil( + (error.reset - Date.now()) / 1000 / 60 / 60, + ); + const hours = timeRemainingHours === 1 ? "hour" : "hours"; + + return streamingJSON({ + type: "error", + value: error.message, + message: `**Unfortunately you’ve exceeded your fair usage limit for today.** Please come back in ${timeRemainingHours} ${hours}. If you require a higher limit, please [make a request](${process.env.RATELIMIT_FORM_URL}).`, + } as ErrorDocument); +} + +async function handleUserBannedError() { + return streamingJSON({ + type: "action", + action: "SHOW_ACCOUNT_LOCKED", + } as ActionDocument); +} + +async function handleGenericError(span: TracingSpan, e: Error) { + reportErrorTelemetry(span, e, e.name, e.message); + return streamingJSON({ + type: "error", + message: e.message, + value: `Sorry, an error occurred: ${e.message}`, + } as ErrorDocument); +} + +export async function handleChatException( + span: TracingSpan, + e: unknown, + chatId: string, + prisma: PrismaClientWithAccelerate, +): Promise { + if (e instanceof AilaAuthenticationError) { + return handleAilaAuthenticationError(span, e); + } + + if (e instanceof AilaThreatDetectionError) { + return handleThreatDetectionError(span, e, chatId, prisma); + } + + if (e instanceof RateLimitExceededError) { + return handleRateLimitError(span, e); + } + + if (e instanceof UserBannedError) { + return handleUserBannedError(); + } + + if (e instanceof Error) { + return handleGenericError(span, e); + } + + throw e; +} diff --git a/apps/nextjs/src/app/api/chat/protocol.ts b/apps/nextjs/src/app/api/chat/protocol.ts index 6fd50d38d..322d43d6a 100644 --- a/apps/nextjs/src/app/api/chat/protocol.ts +++ b/apps/nextjs/src/app/api/chat/protocol.ts @@ -5,7 +5,9 @@ import { import { StreamingTextResponse } from "ai"; export function streamingJSON(message: ErrorDocument | ActionDocument) { - const errorMessage = JSON.stringify(message); + const jsonContent = JSON.stringify(message); + const errorMessage = `0:"${jsonContent.replace(/"/g, '\\"')}"`; + const errorEncoder = new TextEncoder(); return new StreamingTextResponse( diff --git a/apps/nextjs/src/app/api/chat/route.test.ts b/apps/nextjs/src/app/api/chat/route.test.ts index 53ee3fec1..18724f1d2 100644 --- a/apps/nextjs/src/app/api/chat/route.test.ts +++ b/apps/nextjs/src/app/api/chat/route.test.ts @@ -12,6 +12,10 @@ import { Config } from "./config"; const chatId = "test-chat-id"; const userId = "test-user-id"; +jest.mock("./user", () => ({ + fetchAndCheckUser: jest.fn().mockResolvedValue("test-user-id"), +})); + describe("Chat API Route", () => { let testConfig: Config; let mockLLMService: MockLLMService; @@ -32,9 +36,6 @@ describe("Chat API Route", () => { jest.spyOn(mockLLMService, "createChatCompletionStream"); testConfig = { - shouldPerformUserLookup: false, - handleUserLookup: jest.fn(), - mockUserId: userId, createAila: jest.fn().mockImplementation(async (options) => { const ailaConfig = { options: { @@ -92,7 +93,5 @@ describe("Chat API Route", () => { expectTracingSpan("chat-api").toHaveBeenExecutedWith({ chat_id: "test-chat-id", }); - - expect(testConfig.handleUserLookup).not.toHaveBeenCalled(); - }, 30000); + }); }); diff --git a/apps/nextjs/src/app/api/chat/user.test.ts b/apps/nextjs/src/app/api/chat/user.test.ts index b3a6ff30d..4c9c3cedf 100644 --- a/apps/nextjs/src/app/api/chat/user.test.ts +++ b/apps/nextjs/src/app/api/chat/user.test.ts @@ -2,7 +2,7 @@ import { inngest } from "@oakai/core"; import { posthogAiBetaServerClient } from "@oakai/core/src/analytics/posthogAiBetaServerClient"; import { RateLimitExceededError } from "@oakai/core/src/utils/rateLimiting/userBasedRateLimiter"; -import { handleRateLimitError } from "./user"; +import { reportRateLimitError } from "./user"; jest.mock("@oakai/core/src/client", () => ({ inngest: { @@ -12,16 +12,21 @@ jest.mock("@oakai/core/src/client", () => ({ })); describe("chat route user functions", () => { - describe("handleRateLimitError", () => { + describe("reportRateLimitError", () => { it("should report rate limit exceeded to PostHog when userId is provided", async () => { jest.spyOn(posthogAiBetaServerClient, "identify"); jest.spyOn(posthogAiBetaServerClient, "capture"); jest.spyOn(posthogAiBetaServerClient, "shutdown"); - const error = new RateLimitExceededError(100, Date.now() + 3600 * 1000); - const chatId = "testChatId"; + const userId = "testUserId"; + const error = new RateLimitExceededError( + userId, + 100, + Date.now() + 3600 * 1000, + ); + const chatId = "testChatId"; - await handleRateLimitError(error, userId, chatId); + await reportRateLimitError(error, userId, chatId); expect(posthogAiBetaServerClient.identify).toHaveBeenCalledWith({ distinctId: userId, @@ -48,11 +53,15 @@ describe("chat route user functions", () => { posthogAiBetaServerClient: mockPosthogClient, })); - const error = new RateLimitExceededError(10, Date.now() + 3600 * 1000); - const chatId = "testChatId"; const userId = "testUserId"; + const error = new RateLimitExceededError( + userId, + 10, + Date.now() + 3600 * 1000, + ); + const chatId = "testChatId"; - await handleRateLimitError(error, userId, chatId); + await reportRateLimitError(error, userId, chatId); expect(inngest.send).toHaveBeenCalledTimes(1); expect(inngest.send).toHaveBeenCalledWith({ @@ -66,28 +75,5 @@ describe("chat route user functions", () => { }, }); }); - - it("should return an error chat message", async () => { - const mockPosthogClient = { - identify: jest.fn(), - capture: jest.fn(), - shutdown: jest.fn().mockResolvedValue(undefined), - }; - jest.mock("@oakai/core/src/analytics/posthogAiBetaServerClient", () => ({ - posthogAiBetaServerClient: mockPosthogClient, - })); - const error = new RateLimitExceededError(100, Date.now() + 3600 * 1000); - const chatId = "testChatId"; - const userId = "testUserId"; - - const response = await handleRateLimitError(error, userId, chatId); - - expect(response).toEqual({ - type: "error", - value: "Rate limit exceeded", - message: - "**Unfortunately you've exceeded your fair usage limit for today.** Please come back in 1 hour. If you require a higher limit, please [make a request](https://forms.gle/tHsYMZJR367zydsG8).", - }); - }); }); }); diff --git a/apps/nextjs/src/app/api/chat/user.ts b/apps/nextjs/src/app/api/chat/user.ts index 0a1e6e3b4..dfa8bd629 100644 --- a/apps/nextjs/src/app/api/chat/user.ts +++ b/apps/nextjs/src/app/api/chat/user.ts @@ -1,34 +1,17 @@ import { auth, clerkClient } from "@clerk/nextjs/server"; -import { ErrorDocument } from "@oakai/aila/src/protocol/jsonPatchProtocol"; +import { AilaAuthenticationError } from "@oakai/aila"; import { demoUsers, inngest } from "@oakai/core"; import { posthogAiBetaServerClient } from "@oakai/core/src/analytics/posthogAiBetaServerClient"; +import { UserBannedError } from "@oakai/core/src/models/safetyViolations"; import { withTelemetry } from "@oakai/core/src/tracing/serverTracing"; import { rateLimits } from "@oakai/core/src/utils/rateLimiting/rateLimit"; import { RateLimitExceededError } from "@oakai/core/src/utils/rateLimiting/userBasedRateLimiter"; -import { streamingJSON } from "./protocol"; - -export async function handleUserLookup(chatId: string) { - return await withTelemetry( - "chat-user-lookup", - { chat_id: chatId }, - async (userLookupSpan) => { - const result = await fetchAndCheckUser(chatId); - - if ("failureResponse" in result) { - userLookupSpan.setTag("error", true); - userLookupSpan.setTag("error.message", "user lookup failed"); - } - return result; - }, - ); -} - async function checkRateLimit( userId: string, isDemoUser: boolean, chatId: string, -): Promise { +): Promise { return withTelemetry("check-rate-limit", { userId, chatId }, async (span) => { const rateLimiter = isDemoUser ? rateLimits.generations.demo @@ -36,29 +19,36 @@ async function checkRateLimit( try { await rateLimiter.check(userId); - return null; } catch (e) { - if (e instanceof RateLimitExceededError) { - return await handleRateLimitError(e, userId, chatId); - } span.setTag("error", true); if (e instanceof Error) { span.setTag("error.message", e.message); } + + if (e instanceof RateLimitExceededError) { + await reportRateLimitError(e, userId, chatId); + + const timeRemainingHours = Math.ceil( + (e.reset - Date.now()) / 1000 / 60 / 60, + ); + span.setTag("error.type", "RateLimitExceeded"); + span.setTag("rate_limit.reset_hours", timeRemainingHours); + } + throw e; } }); } -export async function handleRateLimitError( +export async function reportRateLimitError( error: RateLimitExceededError, userId: string, chatId: string, -): Promise { +): Promise { return withTelemetry( "handle-rate-limit-error", { chatId, userId }, - async (span) => { + async () => { posthogAiBetaServerClient.identify({ distinctId: userId, }); @@ -83,69 +73,39 @@ export async function handleRateLimitError( reset: new Date(error.reset), }, }); - - // Build user-friendly error message - const timeRemainingHours = Math.ceil( - (error.reset - Date.now()) / 1000 / 60 / 60, - ); - const hours = timeRemainingHours === 1 ? "hour" : "hours"; - const higherLimitMessage = process.env.RATELIMIT_FORM_URL - ? ` If you require a higher limit, please [make a request](${process.env.RATELIMIT_FORM_URL}).` - : ""; - - span.setTag("error", true); - span.setTag("error.type", "RateLimitExceeded"); - span.setTag("error.message", error.message); - span.setTag("rate_limit.reset_hours", timeRemainingHours); - - return { - type: "error", - value: error.message, - message: `**Unfortunately you've exceeded your fair usage limit for today.** Please come back in ${timeRemainingHours} ${hours}.${higherLimitMessage}`, - }; }, ); } -export async function fetchAndCheckUser( - chatId: string, -): Promise<{ userId: string } | { failureResponse: Response }> { +export async function fetchAndCheckUser(chatId: string): Promise { return withTelemetry("fetch-and-check-user", { chatId }, async (span) => { const userId = auth().userId; if (!userId) { span.setTag("error", true); span.setTag("error.message", "Unauthorized"); - return { - failureResponse: new Response("Unauthorized", { - status: 401, - }), - }; + throw new AilaAuthenticationError("No user id"); } const clerkUser = await clerkClient.users.getUser(userId); if (clerkUser.banned) { span.setTag("error", true); span.setTag("error.message", "Account locked"); - return { - failureResponse: streamingJSON({ - type: "action", - action: "SHOW_ACCOUNT_LOCKED", - }), - }; + throw new UserBannedError(userId); } const isDemoUser = demoUsers.isDemoUser(clerkUser); - const rateLimitedMessage = await checkRateLimit(userId, isDemoUser, chatId); - if (rateLimitedMessage) { - span.setTag("error", true); - span.setTag("error.message", "Rate limited"); - return { - failureResponse: streamingJSON(rateLimitedMessage), - }; + try { + await checkRateLimit(userId, isDemoUser, chatId); + } catch (e) { + if (e instanceof RateLimitExceededError) { + span.setTag("error", true); + span.setTag("error.message", "Rate limited"); + } + throw e; } span.setTag("user.id", userId); span.setTag("user.demo", isDemoUser); - return { userId }; + return userId; }); } diff --git a/apps/nextjs/src/app/api/chat/webActionsPlugin.test.ts b/apps/nextjs/src/app/api/chat/webActionsPlugin.test.ts index a43a43b62..289b5a6a3 100644 --- a/apps/nextjs/src/app/api/chat/webActionsPlugin.test.ts +++ b/apps/nextjs/src/app/api/chat/webActionsPlugin.test.ts @@ -133,7 +133,7 @@ describe("onStreamError", () => { const plugin = createWebActionsPlugin(prisma, safetyViolations); await expect(async () => { await plugin.onStreamError( - new AilaThreatDetectionError("test"), + new AilaThreatDetectionError("user_abc", "test"), pluginContext, ); }).rejects.toThrow("test"); @@ -149,7 +149,7 @@ describe("onStreamError", () => { type: "error", value: "Threat detected", message: - "I wasn't able to process your request because a potentially malicious input was detected.", + "I wasn’t able to process your request because a potentially malicious input was detected.", }); }); @@ -170,7 +170,7 @@ describe("onStreamError", () => { const plugin = createWebActionsPlugin(prisma, safetyViolations); await expect(async () => { await plugin.onStreamError( - new AilaThreatDetectionError("test"), + new AilaThreatDetectionError("user_abc", "test"), pluginContext, ); }).rejects.toThrow("test"); diff --git a/apps/nextjs/src/app/faqs/index.tsx b/apps/nextjs/src/app/faqs/index.tsx index 7cea5655d..1f3765452 100644 --- a/apps/nextjs/src/app/faqs/index.tsx +++ b/apps/nextjs/src/app/faqs/index.tsx @@ -398,7 +398,7 @@ const FAQPage = () => { Yes, we provide comprehensive support to assist with any issues - or questions users may have. You can contact us via + or questions users may have. You can contact us via{" "} email. - @@ -91,9 +101,9 @@ export default function HomePage() { tailoring content to your class, Aila can help speed things along. - + - - + diff --git a/apps/nextjs/src/app/manifest.ts b/apps/nextjs/src/app/manifest.ts new file mode 100644 index 000000000..90755d770 --- /dev/null +++ b/apps/nextjs/src/app/manifest.ts @@ -0,0 +1,45 @@ +import type { MetadataRoute } from 'next' + +export default function manifest(): MetadataRoute.Manifest { + return { + name: "Aila: Oak's AI Lesson Assistant", + short_name: 'Aila', + description: 'An AI lesson assistant chatbot for UK teachers to create lessons personalised for their classes, with the aim of reducing teacher workload.', + start_url: '/', + display: 'minimal-ui', + background_color: '#BEF2BD', + theme_color: '#BEF2BD', + icons: [ + { + "src": "/favicon/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/favicon/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "/favicon/apple-touch-icon.png", + "sizes": "180x180", + "type": "image/png" + }, + { + "src": "/favicon/favicon-16x16.png", + "sizes": "16x16", + "type": "image/png" + }, + { + "src": "/favicon/favicon-32x32.png", + "sizes": "32x32", + "type": "image/png" + }, + { + "src": "/favicon/favicon.ico", + "sizes": "48x48 16x16 32x32", + "type": "image/x-icon" + } + ] + } +} \ No newline at end of file diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-history.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-history.tsx index 5ee03f7b5..05ae2b2ac 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-history.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-history.tsx @@ -3,12 +3,14 @@ import * as React from "react"; import { OakIcon } from "@oaknational/oak-components"; +import { usePathname } from "next/navigation"; import { SidebarList } from "@/components/AppComponents/Chat/sidebar-list"; import ChatButton from "./ui/chat-button"; export function ChatHistory() { + const ailaId = usePathname().split("aila/")[1]; return (
    @@ -28,7 +30,10 @@ export function ChatHistory() { AI experiments page - + diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-layout.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-layout.tsx index edd21785d..28a4463e4 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-layout.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-layout.tsx @@ -28,6 +28,7 @@ export const ChatLayout = ({ className }: Readonly) => { useMobileLessonPullOutControl({ ailaStreamingStatus, messages, + lessonPlan, }); return ( diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-lhs-header.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-lhs-header.tsx index cffabc04d..3044334d7 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-lhs-header.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-lhs-header.tsx @@ -17,9 +17,7 @@ const ChatLhsHeader = ({ const router = useRouter(); return ( <> -
    -

    Aila

    - +
    { diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-message/index.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-message/index.tsx index fe00d6d31..deeb9cccf 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-message/index.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-message/index.tsx @@ -18,6 +18,7 @@ import { Message } from "ai"; import { MemoizedReactMarkdownWithStyles } from "@/components/AppComponents/Chat/markdown"; import { useChatModeration } from "@/components/ContextProviders/ChatModerationContext"; +import { Icon } from "@/components/Icon"; import { cn } from "@/lib/utils"; import { ModerationModalHelpers } from "../../FeedbackForms/ModerationFeedbackModal"; @@ -96,21 +97,23 @@ export function ChatMessage({ {matchingModeration && !isSafe(matchingModeration) && ( - + )} @@ -177,10 +180,10 @@ function MessageWrapper({
    {type === "aila" || diff --git a/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/chat-section.tsx b/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/chat-section.tsx index a3050cb51..c67a684fc 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/chat-section.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/chat-section.tsx @@ -34,7 +34,7 @@ const ChatSection = ({ lessonSectionTitlesAndMiniDescriptions[objectKey]?.description } /> - + ); diff --git a/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/flag-button.tsx b/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/flag-button.tsx index 1341245e5..c4e037771 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/flag-button.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/flag-button.tsx @@ -21,7 +21,7 @@ const flagOptions = [ type FlagButtonOptions = typeof flagOptions; -const FlagButton = () => { +const FlagButton = ({ section }: { section: string }) => { const dropdownRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const [selectedRadio, setSelectedRadio] = @@ -67,7 +67,7 @@ const FlagButton = () => { onClickActions={flagSectionContent} setIsOpen={setIsOpen} selectedRadio={selectedRadio} - title={"Flag issue with learning outcome:"} + title={`Flag issue with ${section.toLowerCase()}:`} buttonText={"Send feedback"} isOpen={isOpen} dropdownRef={dropdownRef} diff --git a/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/modify-button.tsx b/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/modify-button.tsx index 3a2897d4d..ac15b9a22 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/modify-button.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/drop-down-section/modify-button.tsx @@ -80,7 +80,7 @@ const ModifyButton = ({ onClickActions={modifySection} setIsOpen={setIsOpen} selectedRadio={selectedRadio} - title={`Ask Aila to modify ${section}:`} + title={`Ask Aila to modify ${section.toLowerCase()}:`} buttonText={"Modify section"} isOpen={isOpen} dropdownRef={dropdownRef} @@ -98,7 +98,10 @@ const ModifyButton = ({ id={`${id}-modify-options-${option.enumValue}`} key={`${id}-modify-options-${option.enumValue}`} value={option.enumValue} - label={option.label} + label={handleLabelText({ + text: option.label, + section, + })} onClick={() => { setSelectedRadio(option); }} @@ -122,4 +125,23 @@ const ModifyButton = ({ ); }; +function handleLabelText({ + text, + section, +}: { + text: string; + section: string; +}): string { + if ( + section === "Misconceptions" || + section === "Keyword learning points" || + section === "Learning cycles" + ) { + if (text.includes("it")) { + return text.replace("it", "them"); + } + } + return text; +} + export default ModifyButton; diff --git a/apps/nextjs/src/components/AppComponents/Chat/header.tsx b/apps/nextjs/src/components/AppComponents/Chat/header.tsx index f908926fa..a2f91c60e 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/header.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/header.tsx @@ -5,6 +5,7 @@ import * as React from "react"; import { OakIcon } from "@oaknational/oak-components"; import { useClerkDemoMetadata } from "hooks/useClerkDemoMetadata"; import Link from "next/link"; +import { usePathname } from "next/navigation"; import { useDemoUser } from "@/components/ContextProviders/Demo"; import { Icon } from "@/components/Icon"; @@ -21,6 +22,8 @@ export function Header() { // Check whether clerk metadata has loaded to prevent the banner from flashing const clerkMetadata = useClerkDemoMetadata(); + const ailaId = usePathname().split("aila/")[1]; + return (
    {clerkMetadata.isSet && demo.isDemoUser && ( @@ -64,7 +67,7 @@ export function Header() {
    diff --git a/apps/nextjs/src/components/AppComponents/Layout/index.tsx b/apps/nextjs/src/components/AppComponents/Layout/index.tsx index 2ab5c7c7e..68df650e3 100644 --- a/apps/nextjs/src/components/AppComponents/Layout/index.tsx +++ b/apps/nextjs/src/components/AppComponents/Layout/index.tsx @@ -1,5 +1,6 @@ "use client"; +import { useDemoUser } from "@/components/ContextProviders/Demo"; import Footer from "@/components/Footer"; import { Header } from "../Chat/header"; @@ -12,10 +13,13 @@ const Layout = ({ children: React.ReactNode; includeFooter?: boolean; }) => { + const isDemoUser = useDemoUser().isDemoUser; return (
    -
    +
    {children}
    {includeFooter &&