Skip to content

Commit

Permalink
Syllabus chatbot UI (#1999)
Browse files Browse the repository at this point in the history
* rename a few things

* remove a few unused flex properties

* simplify conditional

* remove unneessary alignItems

* only affect direct children

* remove rawAnchor

* drawer chat first pass

* connect chat to api

* show at md width

* tweak spacing, update smoot

* fix tests

* add two simple tests

* tweak title, prompts

* bump smoot

* bump smoot

* bump smoot

* bump smoot

* rename css class

* tweak demo height

* bump smoot

* use default collection_name

* restore feature flag

* fix a width issue

* fix comment

* bump smoot

* bump smoot last time (??)

* keep CallToAction border when chat open

* equalize spacing left/right of chat

* kebab-case-the-flag-like-others
  • Loading branch information
ChristopherChudzicki authored Jan 31, 2025
1 parent 46a3898 commit 471e5c1
Show file tree
Hide file tree
Showing 23 changed files with 531 additions and 175 deletions.
1 change: 1 addition & 0 deletions frontends/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ module.exports = {
"**/*.test.tsx",
"**/src/setupJest.ts",
"**/jest-shared-setup.ts",
"**/jsdom-extended.ts",
"**/test-utils/**",
"**/test-utils/**",
"**/webpack.config.js",
Expand Down
2 changes: 1 addition & 1 deletion frontends/jest.jsdom.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { Config } from "@jest/types"
const config: Config.InitialOptions &
Pick<Required<Config.InitialOptions>, "setupFilesAfterEnv"> = {
setupFilesAfterEnv: [resolve(__dirname, "./jest-shared-setup.ts")],
testEnvironment: "jsdom",
testEnvironment: resolve(__dirname, "./jsdom-extended.ts"),
transform: {
"^.+\\.(t|j)sx?$": "@swc/jest",
},
Expand Down
19 changes: 19 additions & 0 deletions frontends/jsdom-extended.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* For some reason Jest's JSDOM environment does not include these, though they
* have been available in NodeJS and web browsers for a while now.
*/
import { TestEnvironment } from "jest-environment-jsdom"
import { EnvironmentContext, JestEnvironmentConfig } from "@jest/environment"

class JSDOMEnvironmentExtended extends TestEnvironment {
constructor(config: JestEnvironmentConfig, context: EnvironmentContext) {
super(config, context)

this.global.TransformStream = TransformStream
this.global.ReadableStream = ReadableStream
this.global.Response = Response
this.global.TextDecoderStream = TextDecoderStream
}
}

export default JSDOMEnvironmentExtended
2 changes: 1 addition & 1 deletion frontends/main/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const config: Config.InitialOptions = {
...baseConfig,
setupFilesAfterEnv: [
...baseConfig.setupFilesAfterEnv,
"./test-utils/setupJest.ts",
"./test-utils/setupJest.tsx",
],
moduleNameMapper: {
...baseConfig.moduleNameMapper,
Expand Down
2 changes: 2 additions & 0 deletions frontends/main/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ const nextConfig = {
]
},

transpilePackages: ["@mitodl/smoot-design/ai"],

images: {
remotePatterns: [
{
Expand Down
4 changes: 3 additions & 1 deletion frontends/main/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@
"@emotion/cache": "^11.13.1",
"@emotion/styled": "^11.11.0",
"@mitodl/course-search-utils": "3.3.2",
"@mitodl/smoot-design": "^2.0.1",
"@mitodl/smoot-design": "^3.0.1",
"@next/bundle-analyzer": "^14.2.15",
"@nlux/react": "^2.17.1",
"@nlux/themes": "^2.17.1",
"@remixicon/react": "^4.2.0",
"@sentry/nextjs": "^8.36.0",
"@tanstack/react-query": "^4.36.1",
"api": "workspace:*",
"classnames": "^2.5.1",
"formik": "^2.4.6",
"iso-639-1": "^3.1.4",
"lodash": "^4.17.21",
Expand Down Expand Up @@ -54,6 +55,7 @@
"http-proxy-middleware": "^3.0.0",
"jest": "^29.7.0",
"jest-extended": "^4.0.2",
"jest-next-dynamic-ts": "^0.1.1",
"ol-test-utilities": "0.0.0",
"ts-jest": "^29.2.4",
"typescript": "^5"
Expand Down
42 changes: 16 additions & 26 deletions frontends/main/src/app-pages/ChatSyllabusPage/ChatSyllabusPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,7 @@ import StyledContainer from "@/page-components/StyledContainer/StyledContainer"
import { InputLabel, Select } from "@mui/material"
import { AiChat, AiChatProps } from "@mitodl/smoot-design/ai"
import { extractJSONFromComment } from "ol-utilities"

function getCookie(name: string) {
const value = `; ${document.cookie}`
const parts = value.split(`; ${name}=`)
if (parts.length === 2) {
return parts.pop()?.split(";").shift()
}
}
import { getCsrfToken } from "@/common/utils"

const STARTERS: AiChatProps["conversationStarters"] = [
{ content: "What are the prerequisites for this course?" },
Expand Down Expand Up @@ -47,24 +40,16 @@ const StyledDebugPre = styled.pre({
width: "80%",
whiteSpace: "pre-wrap",
})
const AiChatStyled = styled(AiChat)({
height: "60vh",
})

const ChatSyllabusPage = () => {
const botEnabled = useFeatureFlagEnabled(FeatureFlags.RecommendationBot)
const [readableId, setReadableId] = useState("18.06SC+fall_2011")
const [collectionName, setCollectionName] = useState("content_files")
const [debugInfo, setDebugInfo] = useState("")

const parseContent = (content: string | unknown) => {
if (typeof content !== "string") {
return ""
}
const contentParts = content.split("<!--")
if (contentParts.length > 1) {
setDebugInfo(contentParts[1])
}
return contentParts[0]
}

return (
<StyledContainer>
{
Expand Down Expand Up @@ -121,17 +106,16 @@ const ChatSyllabusPage = () => {
</div>
</FormContainer>
</form>
<AiChat
<AiChatStyled
title="Syllabus Chatbot"
initialMessages={INITIAL_MESSAGES}
conversationStarters={STARTERS}
parseContent={parseContent}
requestOpts={{
apiUrl: `${process.env.NEXT_PUBLIC_MITOL_API_BASE_URL}/api/v0/syllabus_agent/`,
headersOpts: {
"X-CSRFToken":
getCookie(
process.env.NEXT_PUBLIC_CSRF_COOKIE_NAME || "csrftoken",
) ?? "",
fetchOpts: {
headers: {
"X-CSRFToken": getCsrfToken(),
},
},
transformBody: (messages) => {
return {
Expand All @@ -140,6 +124,12 @@ const ChatSyllabusPage = () => {
collection_name: collectionName,
}
},
onFinish: (message) => {
const contentParts = message.content.split("<!--")
if (contentParts.length > 1) {
setDebugInfo(contentParts[1])
}
},
}}
/>
{debugInfo &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ const AUTH_TEXT_DATA = {
text: "As a member, get personalized recommendations, curate learning lists, and follow your areas of interest.",
linkProps: {
children: "Sign Up for Free",
rawAnchor: true,
href: urls.login({ pathname: urls.DASHBOARD_HOME }),
},
},
Expand Down
2 changes: 1 addition & 1 deletion frontends/main/src/common/feature_flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@

export enum FeatureFlags {
EnableEcommerce = "enable-ecommerce",
DrawerV2Enabled = "lr_drawer_v2",
LrDrawerChatbot = "lr-drawer-chatbot",
RecommendationBot = "recommendation-bot",
}
23 changes: 22 additions & 1 deletion frontends/main/src/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,25 @@ const aggregateCourseCounts = (
)
}

export { getSearchParamMap, aggregateProgramCounts, aggregateCourseCounts }
function getCookie(name: string) {
const value = `; ${document.cookie}`
const parts = value.split(`; ${name}=`)
if (parts.length === 2) {
return parts.pop()?.split(";").shift()
}
}
/**
* Returns CsrfToken from cookie if it is present
*/
const getCsrfToken = () => {
return (
getCookie(process.env.NEXT_PUBLIC_CSRF_COOKIE_NAME || "csrftoken") ?? ""
)
}

export {
getSearchParamMap,
aggregateProgramCounts,
aggregateCourseCounts,
getCsrfToken,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React from "react"
import AiChatSyllabus from "./AiChatSyllabus"
import { renderWithProviders, screen } from "@/test-utils"
import { factories, setMockResponse, urls } from "api/test-utils"

/**
* Note: This component is primarily tested in @mitodl/smoot-design.
*
* Here we just check a few config settings.
*/
describe("AiChatSyllabus", () => {
test("Greets authenticated user by name", async () => {
const resource = factories.learningResources.course()
const user = factories.user.user()

// Sanity
expect(user.profile.name).toBeTruthy()

setMockResponse.get(urls.userMe.get(), user)
renderWithProviders(
<AiChatSyllabus onClose={jest.fn()} resource={resource} />,
)

// byAll because there are two instances, one is SR-only in an aria-live area
// check for username and resource title
await screen.findAllByText(
new RegExp(`Hello ${user.profile.name}.*${resource.title}.*`),
)
})

test("Greets anonymous user generically", async () => {
const resource = factories.learningResources.course()

setMockResponse.get(urls.userMe.get(), {}, { code: 403 })
renderWithProviders(
<AiChatSyllabus onClose={jest.fn()} resource={resource} />,
)

// byAll because there are two instances, one is SR-only in an aria-live area
// check for username and resource title
await screen.findAllByText(new RegExp(`Hello and.*${resource.title}.*`))
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import * as React from "react"
import type { AiChatProps } from "@mitodl/smoot-design/ai"
import { getCsrfToken } from "@/common/utils"
import { LearningResource } from "api"
import { useUserMe } from "api/hooks/user"
import type { User } from "api/hooks/user"
import dynamic from "next/dynamic"
import type { SyllabusChatRequestRequest } from "api/v0"

const AiChat = dynamic(
() => import("@mitodl/smoot-design/ai").then((mod) => mod.AiChat),
{ ssr: false },
)

const STARTERS: AiChatProps["conversationStarters"] = [
{ content: "What is this course about?" },
{ content: "What are the prerequisites for this course?" },
{ content: "How will this course be graded?" },
]

const getInitialMessage = (
resource: LearningResource,
user?: User,
): AiChatProps["initialMessages"] => {
const grettings = user?.profile?.name
? `Hello ${user.profile.name}, `
: "Hello and "
return [
{
content: `${grettings} welcome to **${resource.title}**. How can I assist you today?`,
role: "assistant",
},
]
}

type AiChatSyllabusProps = {
onClose: () => void
resource?: LearningResource
className?: string
}

const AiChatSyllabus: React.FC<AiChatSyllabusProps> = ({
onClose,
resource,
...props
}) => {
const user = useUserMe()
if (!resource) return null

return (
<AiChat
data-testid="ai-chat-syllabus"
conversationStarters={STARTERS}
initialMessages={getInitialMessage(resource, user.data)}
chatId={`chat-${resource?.readable_id}`}
title="Ask Tim about this course"
onClose={onClose}
requestOpts={{
apiUrl: `${process.env.NEXT_PUBLIC_MITOL_API_BASE_URL}/api/v0/syllabus_agent/`,
fetchOpts: {
headers: {
"X-CSRFToken": getCsrfToken(),
},
},
transformBody: (messages) => {
const body: SyllabusChatRequestRequest = {
collection_name: "content_files",
message: messages[messages.length - 1].content,
readable_id: resource?.readable_id,
}
return body
},
}}
{...props}
/>
)
}

export default AiChatSyllabus
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { RESOURCE_DRAWER_QUERY_PARAM } from "@/common/urls"
import { LearningResource, ResourceTypeEnum } from "api"
import { makeUserSettings } from "@/test-utils/factories"
import type { User } from "api/hooks/user"
import { usePostHog } from "posthog-js/react"

jest.mock("./LearningResourceExpanded", () => {
const actual = jest.requireActual("./LearningResourceExpanded")
Expand All @@ -23,16 +24,11 @@ jest.mock("./LearningResourceExpanded", () => {
})

const mockedPostHogCapture = jest.fn()

jest.mock("posthog-js/react", () => ({
PostHogProvider: (props: { children: React.ReactNode }) => (
<div data-testid="phProvider">{props.children}</div>
),

usePostHog: () => {
return { capture: mockedPostHogCapture }
},
}))
jest.mock("posthog-js/react")
jest.mocked(usePostHog).mockReturnValue(
// @ts-expect-error Not mocking all of posthog
{ capture: mockedPostHogCapture },
)

describe("LearningResourceDrawer", () => {
const setupApis = (
Expand Down
Loading

0 comments on commit 471e5c1

Please sign in to comment.