From f0ff4942af568b80bdb23d42284fd544a25ffad4 Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 01:30:20 +0200 Subject: [PATCH 1/4] feat: app-wide, page-context-aware chatbot grounded in owned surface data --- server/src/modules/ai/ai.controller.spec.ts | 18 ++- server/src/modules/ai/ai.controller.ts | 12 +- server/src/modules/ai/ai.module.ts | 4 + server/src/modules/ai/ai.service.spec.ts | 150 ++++++++++++++++-- server/src/modules/ai/ai.service.ts | 142 ++++++++++++++++- server/src/modules/ai/chat-context.spec.ts | 108 +++++++++++++ server/src/modules/ai/chat-context.ts | 142 +++++++++++++++++ server/src/modules/ai/dto/chat.dto.ts | 59 ++++++- server/talkup-backend-bruno/ai/post chat.bru | 8 +- .../organisms/chatbot/ChatWidget.spec.tsx | 36 +++++ .../organisms/chatbot/ChatWidget.tsx | 12 +- web/src/config/navigation-contexts.spec.ts | 4 +- web/src/config/navigation-contexts.ts | 7 + web/src/hooks/ui/useChatContext.spec.ts | 69 ++++++++ web/src/hooks/ui/useChatContext.ts | 48 ++++++ web/src/routes/__root.tsx | 4 + web/src/routes/ai-chat.tsx | 15 +- web/src/services/ai/types.ts | 16 ++ 18 files changed, 823 insertions(+), 31 deletions(-) create mode 100644 server/src/modules/ai/chat-context.spec.ts create mode 100644 server/src/modules/ai/chat-context.ts create mode 100644 web/src/hooks/ui/useChatContext.spec.ts create mode 100644 web/src/hooks/ui/useChatContext.ts diff --git a/server/src/modules/ai/ai.controller.spec.ts b/server/src/modules/ai/ai.controller.spec.ts index 303d3a3c..f790b826 100644 --- a/server/src/modules/ai/ai.controller.spec.ts +++ b/server/src/modules/ai/ai.controller.spec.ts @@ -140,17 +140,29 @@ describe("AiController", () => { expect(res).toEqual(true); }); - it("chat should call service and return the reply", async () => { + it("chat should call service with the caller's id and return the reply", async () => { const dto = { message: "How do I answer behavioral questions?" } as any; const serviceResult = { reply: "Use the STAR method." }; (mockAiService.chat as jest.Mock).mockResolvedValueOnce(serviceResult); - const res = await controller.chat(dto); + const res = await controller.chat(dto, "user-1"); - expect(mockAiService.chat).toHaveBeenCalledWith(dto); + expect(mockAiService.chat).toHaveBeenCalledWith(dto, "user-1"); expect(res).toEqual(serviceResult); }); + it("guards the chat route with the ThrottlerGuard so @Throttle fires", () => { + // @Throttle is a no-op without an explicit route ThrottlerGuard. + const guards = Reflect.getMetadata( + "__guards__", + AiController.prototype.chat, + ) as unknown[] | undefined; + const names = (guards ?? []).map((g: any) => + typeof g === "function" ? g.name : g?.constructor?.name, + ); + expect(names).toContain("ThrottlerGuard"); + }); + it("addTranscripts should call service and return result", async () => { const id = "i1"; const dto = { diff --git a/server/src/modules/ai/ai.controller.ts b/server/src/modules/ai/ai.controller.ts index c236dccc..84288fbd 100644 --- a/server/src/modules/ai/ai.controller.ts +++ b/server/src/modules/ai/ai.controller.ts @@ -23,6 +23,7 @@ import { } from "@nestjs/swagger"; import { UsePipes } from "@nestjs/common/decorators/core/use-pipes.decorator"; +import { Throttle, ThrottlerGuard } from "@nestjs/throttler"; import { PostValidationPipe } from "@common/pipes/PostValidationPipe"; import { AccessTokenGuard } from "@common/guards/accessToken.guard"; @@ -68,9 +69,16 @@ export class AiController { description: "The assistant is unavailable.", }) @UsePipes(new PostValidationPipe()) + // Class-level AccessTokenGuard runs first (sets req.userId); ThrottlerGuard + // then enforces the per-user budget on this LLM-cost endpoint. + @UseGuards(ThrottlerGuard) + @Throttle({ default: { limit: 20, ttl: 60000 } }) @Post("chat") - async chat(@Body() chatDto: ChatDto): Promise { - return this.aiService.chat(chatDto); + async chat( + @Body() chatDto: ChatDto, + @UserId() userId: string, + ): Promise { + return this.aiService.chat(chatDto, userId); } @ApiCreatedResponse({ diff --git a/server/src/modules/ai/ai.module.ts b/server/src/modules/ai/ai.module.ts index 72ae9c75..a5b43592 100644 --- a/server/src/modules/ai/ai.module.ts +++ b/server/src/modules/ai/ai.module.ts @@ -12,11 +12,15 @@ import { AiController } from "./ai.controller"; import { AiService } from "./ai.service"; import { SimulationModule } from "../simulation/simulation.module"; import { ApplicationsModule } from "../applications/applications.module"; +import { AgendaModule } from "../agenda/agenda.module"; +import { NotesModule } from "../notes/notes.module"; @Module({ imports: [ SimulationModule, ApplicationsModule, + AgendaModule, + NotesModule, TypeOrmModule.forFeature([ai_interview, ai_transcript, user]), HttpModule.register({ timeout: 5000, diff --git a/server/src/modules/ai/ai.service.spec.ts b/server/src/modules/ai/ai.service.spec.ts index 9c42e80a..7acde47f 100644 --- a/server/src/modules/ai/ai.service.spec.ts +++ b/server/src/modules/ai/ai.service.spec.ts @@ -18,6 +18,8 @@ import { SimulationContextService } from "../simulation/simulation-context.servi import { SimulationPromotionService } from "../simulation/simulation-promotion.service"; import { SimulationVerbalAnalysisService } from "../simulation/simulation-verbal-analysis.service"; import { ApplicationsService } from "../applications/applications.service"; +import { AgendaService } from "../agenda/agenda.service"; +import { NotesService } from "../notes/notes.service"; import { ChatRole } from "./dto/chat.dto"; const mockGroqCreate = jest.fn(); @@ -49,6 +51,8 @@ describe("AiService", () => { let mockContext: any; let mockVerbalAnalysis: any; let mockApplicationsService: any; + let mockAgendaService: any; + let mockNotesService: any; beforeEach(async () => { mockGroqCreate.mockReset(); @@ -106,6 +110,15 @@ describe("AiService", () => { mockApplicationsService = { ensureCvSnapshot: jest.fn(), + getRoadmap: jest.fn(), + }; + + mockAgendaService = { + listForRange: jest.fn(), + }; + + mockNotesService = { + findAll: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ @@ -127,6 +140,8 @@ describe("AiService", () => { useValue: mockVerbalAnalysis, }, { provide: ApplicationsService, useValue: mockApplicationsService }, + { provide: AgendaService, useValue: mockAgendaService }, + { provide: NotesService, useValue: mockNotesService }, ], }).compile(); @@ -736,13 +751,16 @@ describe("AiService", () => { choices: [{ message: { content: " Use the STAR method. " } }], }); - const res = await service.chat({ - message: "How do I answer behavioral questions?", - history: [ - { role: ChatRole.USER, content: "hi" }, - { role: ChatRole.ASSISTANT, content: "hello" }, - ], - }); + const res = await service.chat( + { + message: "How do I answer behavioral questions?", + history: [ + { role: ChatRole.USER, content: "hi" }, + { role: ChatRole.ASSISTANT, content: "hello" }, + ], + }, + "user-1", + ); expect(res).toEqual({ reply: "Use the STAR method." }); @@ -761,7 +779,7 @@ describe("AiService", () => { choices: [{ message: { content: "Sure!" } }], }); - const res = await service.chat({ message: "help" }); + const res = await service.chat({ message: "help" }, "user-1"); expect(res).toEqual({ reply: "Sure!" }); expect(mockGroqCreate.mock.calls[0][0].messages).toHaveLength(2); @@ -770,7 +788,7 @@ describe("AiService", () => { it("throws InternalServerErrorException when the LLM call fails", async () => { mockGroqCreate.mockRejectedValueOnce(new Error("provider down")); - await expect(service.chat({ message: "help" })).rejects.toThrow( + await expect(service.chat({ message: "help" }, "user-1")).rejects.toThrow( InternalServerErrorException, ); }); @@ -780,10 +798,122 @@ describe("AiService", () => { choices: [{ message: { content: " " } }], }); - await expect(service.chat({ message: "help" })).rejects.toThrow( + await expect(service.chat({ message: "help" }, "user-1")).rejects.toThrow( InternalServerErrorException, ); }); + + describe("context grounding", () => { + const systemOf = () => + mockGroqCreate.mock.calls[0][0].messages[0].content; + + it("injects roadmap context resolved from an owned application", async () => { + mockApplicationsService.ensureCvSnapshot.mockResolvedValue({ + company_name: "Datadog", + job_title: "SRE", + }); + mockApplicationsService.getRoadmap.mockResolvedValue({ + match_score: 72, + summary: "Solid fit", + topics: [ + { + title: "Kubernetes", + priority: "HIGH", + rationale: "gap", + gap: true, + }, + ], + talking_points: [{ mission: "on-call", angle: "ran pager duty" }], + }); + mockGroqCreate.mockResolvedValueOnce({ + choices: [{ message: { content: "ok" } }], + }); + + await service.chat( + { + message: "why this topic?", + context: { surface: "roadmap", applicationId: "app-1" } as any, + }, + "user-1", + ); + + expect(mockApplicationsService.getRoadmap).toHaveBeenCalledWith( + "user-1", + "app-1", + ); + expect(systemOf()).toContain("Current page context"); + expect(systemOf()).toContain("Kubernetes"); + expect(systemOf()).toContain("72/100"); + }); + + it("injects the user's upcoming agenda events", async () => { + mockAgendaService.listForRange.mockResolvedValue([ + { + title: "Datadog interview", + start_at: new Date("2026-08-01T09:00:00.000Z"), + location: "Zoom", + }, + ]); + mockGroqCreate.mockResolvedValueOnce({ + choices: [{ message: { content: "ok" } }], + }); + + await service.chat( + { + message: "when is my next interview?", + context: { surface: "agenda" } as any, + }, + "user-1", + ); + + expect(mockAgendaService.listForRange).toHaveBeenCalled(); + expect(systemOf()).toContain("Datadog interview"); + }); + + it("degrades to no context (still replies) when resolution throws", async () => { + // A foreign/missing application makes the owner-guarded loader throw; + // the chat must still answer, just without grounding. + mockApplicationsService.ensureCvSnapshot.mockRejectedValue( + new Error("not found"), + ); + mockGroqCreate.mockResolvedValueOnce({ + choices: [{ message: { content: "generic reply" } }], + }); + + const res = await service.chat( + { + message: "hi", + context: { surface: "roadmap", applicationId: "foreign" } as any, + }, + "user-1", + ); + + expect(res).toEqual({ reply: "generic reply" }); + expect(systemOf()).not.toContain("Current page context"); + }); + + it("scopes notes context to the application when applicationId is given", async () => { + mockNotesService.findAll.mockResolvedValue([ + { title: "Prep", content: "

Ask about on-call

" }, + ]); + mockGroqCreate.mockResolvedValueOnce({ + choices: [{ message: { content: "ok" } }], + }); + + await service.chat( + { + message: "what did I note?", + context: { surface: "notes", applicationId: "app-1" } as any, + }, + "user-1", + ); + + expect(mockNotesService.findAll).toHaveBeenCalledWith("user-1", { + applicationId: "app-1", + }); + expect(systemOf()).toContain("Ask about on-call"); + }); + }); }); describe("getInterviewById", () => { diff --git a/server/src/modules/ai/ai.service.ts b/server/src/modules/ai/ai.service.ts index 02727868..c02c2c94 100644 --- a/server/src/modules/ai/ai.service.ts +++ b/server/src/modules/ai/ai.service.ts @@ -27,8 +27,15 @@ import { GetInterviewsQueryDto } from "./dto/getInterviewsQuery.dto"; import { CreateAiTranscriptsDto } from "./dto/createAiTranscripts.dto"; import { CreateAiInterviewResponseDto } from "./dto/createAiInterviewResponse.dto"; import { InterviewSessionDto } from "./dto/interviewSession.dto"; -import { ChatDto } from "./dto/chat.dto"; +import { ChatDto, ChatSurface, ChatContextDto } from "./dto/chat.dto"; import { ChatResponseDto } from "./dto/chatResponse.dto"; +import { + formatRoadmapContext, + formatSimulationContext, + formatAgendaContext, + formatNotesContext, + wrapContextForPrompt, +} from "./chat-context"; import { SimulationCapacityService } from "../simulation/simulation-capacity.service"; import { SimulationContextService } from "../simulation/simulation-context.service"; @@ -37,6 +44,8 @@ import { SimulationVerbalAnalysisService } from "../simulation/simulation-verbal import { loadSimulationConfig } from "../simulation/simulation.config"; import { ApplicationsService } from "../applications/applications.service"; import { buildSimulationContextFromApplication } from "../simulation/simulation-application-context"; +import { AgendaService } from "../agenda/agenda.service"; +import { NotesService } from "../notes/notes.service"; const CHATBOT_SYSTEM_PROMPT = "You are TalkUp AI, a friendly interview-preparation coach embedded in the " + @@ -73,6 +82,8 @@ export class AiService { private readonly promotion: SimulationPromotionService, private readonly verbalAnalysis: SimulationVerbalAnalysisService, private readonly applicationsService: ApplicationsService, + private readonly agendaService: AgendaService, + private readonly notesService: NotesService, ) { this.logger = new Logger(AiService.name); } @@ -81,9 +92,18 @@ export class AiService { return this.capacity.getSnapshot(); } - async chat(dto: ChatDto): Promise { + async chat(dto: ChatDto, userId: string): Promise { + // Resolve the current page's data from the caller's OWNED ids and inject it + // as grounding context. Never trust a client-supplied context blob (#154). + const contextBlock = dto.context + ? await this.resolveChatContext(dto.context, userId) + : null; + + const systemPrompt = + CHATBOT_SYSTEM_PROMPT + wrapContextForPrompt(contextBlock); + const messages: Groq.Chat.Completions.ChatCompletionMessageParam[] = [ - { role: "system", content: CHATBOT_SYSTEM_PROMPT }, + { role: "system", content: systemPrompt }, ...(dto.history ?? []).map((turn) => ({ role: turn.role, content: turn.content, @@ -117,6 +137,122 @@ export class AiService { } } + /** + * Resolve a compact grounding block for the current page from the caller's + * OWNED ids. Each branch loads via an owner-guarded service (throws on a + * foreign/missing id), so a client can only ground on its own data. A + * resolution failure degrades to no context rather than failing the whole + * chat — the assistant just answers without page grounding. + */ + private async resolveChatContext( + context: ChatContextDto, + userId: string, + ): Promise { + try { + switch (context.surface) { + case ChatSurface.ROADMAP: { + if (!context.applicationId) return null; + const app = await this.applicationsService.ensureCvSnapshot( + userId, + context.applicationId, + ); + const roadmap = await this.applicationsService.getRoadmap( + userId, + context.applicationId, + ); + return formatRoadmapContext(app, roadmap); + } + + case ChatSurface.CV: { + if (!context.applicationId) return null; + // Same owned application data the roadmap grounds on, minus the path — + // useful before a roadmap exists (the CV-analysis surface). + const app = await this.applicationsService.ensureCvSnapshot( + userId, + context.applicationId, + ); + return buildSimulationContextFromApplication(app) || null; + } + + case ChatSurface.SIMULATION: { + const interviewId = + context.interviewId ?? + (context.applicationId + ? await this.latestInterviewIdForApplication( + userId, + context.applicationId, + ) + : null); + if (!interviewId) return null; + + const interview = await this.getInterviewById( + interviewId, + userId, + true, + ); + const analysis = await this.verbalAnalysis + .getForInterview(interviewId, userId) + .catch(() => null); + const app = context.applicationId + ? await this.applicationsService + .ensureCvSnapshot(userId, context.applicationId) + .catch(() => null) + : null; + return formatSimulationContext( + app, + interview, + interview.transcripts, + analysis?.overall_score ?? null, + ); + } + + case ChatSurface.AGENDA: { + // Upcoming events over the next 30 days. + const from = new Date(); + const to = new Date(from.getTime() + 30 * 24 * 60 * 60 * 1000); + const events = await this.agendaService.listForRange( + userId, + from, + to, + ); + return formatAgendaContext(events); + } + + case ChatSurface.NOTES: { + const notes = context.applicationId + ? await this.notesService.findAll(userId, { + applicationId: context.applicationId, + }) + : await this.notesService.findAll(userId, {}); + return formatNotesContext( + notes, + context.applicationId ? "application" : "all", + ); + } + + default: + return null; + } + } catch (error) { + this.logger.warn( + `Chat context resolution failed for surface ${context.surface} (user ${userId}): ${(error as Error).message}`, + ); + return null; + } + } + + /** Most recent interview the user ran for an application, or null. */ + private async latestInterviewIdForApplication( + userId: string, + applicationId: string, + ): Promise { + const interview = await this.aiInterviewRepository.findOne({ + where: { user_id: userId, application_id: applicationId }, + order: { created_at: "DESC" }, + }); + return interview?.interview_id ?? null; + } + async createInterview( dto: CreateAiInterviewDto, userId: string, diff --git a/server/src/modules/ai/chat-context.spec.ts b/server/src/modules/ai/chat-context.spec.ts new file mode 100644 index 00000000..d236200e --- /dev/null +++ b/server/src/modules/ai/chat-context.spec.ts @@ -0,0 +1,108 @@ +import { + formatRoadmapContext, + formatSimulationContext, + formatAgendaContext, + formatNotesContext, + wrapContextForPrompt, + MAX_CHAT_CONTEXT_CHARS, +} from "./chat-context"; + +describe("chat-context formatters", () => { + describe("formatRoadmapContext", () => { + it("includes the score, summary, topics and talking points", () => { + const out = formatRoadmapContext( + { company_name: "Datadog", job_title: "SRE" }, + { + match_score: 72, + summary: "Solid fit", + topics: [ + { + title: "Kubernetes", + priority: "HIGH", + rationale: "core gap", + gap: true, + }, + ], + talking_points: [{ mission: "on-call", angle: "ran pager duty" }], + }, + ); + expect(out).toContain("SRE at Datadog"); + expect(out).toContain("72/100"); + expect(out).toContain("Solid fit"); + expect(out).toContain("Kubernetes"); + expect(out).toContain("[gap]"); + expect(out).toContain("on-call"); + }); + }); + + describe("formatSimulationContext", () => { + it("labels turns by speaker and surfaces the score", () => { + const out = formatSimulationContext( + { company_name: "Datadog", job_title: "SRE" }, + { status: "COMPLETED", score: 80, feedback: "Good pacing" }, + [ + { who_stated: "ai", content: "Tell me about a failure." }, + { who_stated: "user", content: "We had an outage…" }, + ], + 75, + ); + expect(out).toContain("Interviewer: Tell me about a failure."); + expect(out).toContain("Candidate: We had an outage"); + expect(out).toContain("80/100"); + expect(out).toContain("75/100"); + }); + }); + + describe("formatAgendaContext", () => { + it("lists upcoming events", () => { + const out = formatAgendaContext([ + { + title: "Datadog interview", + start_at: new Date("2026-08-01T09:00:00.000Z"), + location: "Zoom", + } as any, + ]); + expect(out).toContain("Datadog interview"); + expect(out).toContain("Zoom"); + }); + + it("states clearly when the agenda is empty", () => { + expect(formatAgendaContext([])).toMatch(/no upcoming events/i); + }); + }); + + describe("formatNotesContext", () => { + it("strips HTML from note previews", () => { + const out = formatNotesContext( + [{ title: "Prep", content: "

Ask about on-call

" }], + "application", + ); + expect(out).toContain("Prep"); + expect(out).toContain("Ask about on-call"); + expect(out).not.toContain("

"); + }); + + it("distinguishes the empty message by scope", () => { + expect(formatNotesContext([], "application")).toMatch( + /no notes for this application/i, + ); + expect(formatNotesContext([], "all")).toMatch(/no notes yet/i); + }); + }); + + describe("truncation + wrapping", () => { + it("caps a huge block at the max length", () => { + const many = Array.from({ length: 500 }, (_, i) => ({ + title: `Note ${i}`, + content: "x".repeat(100), + })); + const out = formatNotesContext(many, "all"); + expect(out.length).toBeLessThanOrEqual(MAX_CHAT_CONTEXT_CHARS + 20); + }); + + it("wraps a block under a context heading and returns '' for null", () => { + expect(wrapContextForPrompt("hello")).toContain("Current page context"); + expect(wrapContextForPrompt(null)).toBe(""); + }); + }); +}); diff --git a/server/src/modules/ai/chat-context.ts b/server/src/modules/ai/chat-context.ts new file mode 100644 index 00000000..b7d4a626 --- /dev/null +++ b/server/src/modules/ai/chat-context.ts @@ -0,0 +1,142 @@ +import type { RoadmapExtraction } from "@common/utils/groqExtraction"; +import type { agenda_event } from "@entities/agenda.entity"; + +/** + * Compact grounding blocks for the chatbot. Each formatter turns owned rows the + * server already resolved into a short, plain-text section for the system + * prompt — never raw JSON, always length-capped so a big roadmap or a long + * notes list can't blow the context window or the token budget. + * + * These are pure functions of already-owned data: ownership and loading happen + * upstream (in the resolver), mirroring the #154 pattern. + */ + +/** Hard cap per context block, matching the simulation context builder. */ +export const MAX_CHAT_CONTEXT_CHARS = 6000; + +const truncate = (text: string): string => + text.length <= MAX_CHAT_CONTEXT_CHARS + ? text + : `${text.slice(0, MAX_CHAT_CONTEXT_CHARS)}\n…(truncated)`; + +const applicationHeading = (app: { + company_name?: string | null; + job_title?: string | null; +}): string => { + const parts = [app.job_title, app.company_name].filter(Boolean); + return parts.length ? parts.join(" at ") : "this application"; +}; + +/** Roadmap (F6 preparation path) block: score, summary, gap topics, talking points. */ +export const formatRoadmapContext = ( + app: { company_name?: string | null; job_title?: string | null }, + roadmap: RoadmapExtraction, +): string => { + const lines: string[] = [ + `The user is viewing the preparation roadmap for ${applicationHeading(app)}.`, + `Match score: ${roadmap.match_score}/100.`, + ]; + if (roadmap.summary) lines.push(`Summary: ${roadmap.summary}`); + + if (roadmap.topics.length) { + lines.push("Preparation topics (priority — title — why):"); + for (const t of roadmap.topics) { + const gap = t.gap ? " [gap]" : ""; + lines.push(`- ${t.priority}${gap} — ${t.title}: ${t.rationale}`); + } + } + + if (roadmap.talking_points.length) { + lines.push("Talking points to bring to the interview (mission — angle):"); + for (const p of roadmap.talking_points) { + lines.push(`- ${p.mission}: ${p.angle}`); + } + } + + return truncate(lines.join("\n")); +}; + +/** Simulation debrief block: the interview's score/feedback + transcript turns. */ +export const formatSimulationContext = ( + app: { company_name?: string | null; job_title?: string | null } | null, + interview: { + status?: string | null; + score?: number | null; + feedback?: string | null; + }, + transcripts: { who_stated?: string | null; content?: string | null }[], + overallScore?: number | null, +): string => { + const lines: string[] = [ + `The user is reviewing a simulation${ + app ? ` for ${applicationHeading(app)}` : "" + }.`, + ]; + if (interview.status) lines.push(`Session status: ${interview.status}.`); + if (typeof interview.score === "number") + lines.push(`Score: ${interview.score}/100.`); + if (interview.feedback) lines.push(`Feedback: ${interview.feedback}`); + if (typeof overallScore === "number") + lines.push(`Verbal analysis overall score: ${overallScore}/100.`); + + if (transcripts.length) { + lines.push("Transcript (most recent turns):"); + // Keep the tail — the end of the interview is the most useful to debrief. + for (const t of transcripts.slice(-12)) { + const who = t.who_stated === "ai" ? "Interviewer" : "Candidate"; + if (t.content) lines.push(`${who}: ${t.content}`); + } + } + + return truncate(lines.join("\n")); +}; + +/** Agenda block: the user's upcoming events, next one first. */ +export const formatAgendaContext = (events: agenda_event[]): string => { + if (!events.length) { + return "The user's agenda has no upcoming events."; + } + const lines = ["The user's upcoming agenda events (soonest first):"]; + for (const e of events) { + const when = e.start_at + ? new Date(e.start_at).toISOString() + : "unknown time"; + const loc = e.location ? ` @ ${e.location}` : ""; + lines.push(`- ${when}: ${e.title}${loc}`); + } + return truncate(lines.join("\n")); +}; + +const stripHtml = (html: string | null | undefined): string => + (html ?? "") + .replace(/<[^>]*>/g, " ") + .replace(/\s+/g, " ") + .trim(); + +/** Notes block: the notes in view (titles + short previews). */ +export const formatNotesContext = ( + notes: { title: string; content?: string | null }[], + scope: "application" | "all", +): string => { + if (!notes.length) { + return scope === "application" + ? "The user has no notes for this application yet." + : "The user has no notes yet."; + } + const lines = [ + scope === "application" + ? "The user's notes for this application:" + : "The user's notes:", + ]; + for (const n of notes.slice(0, 20)) { + const preview = stripHtml(n.content).slice(0, 200); + lines.push(`- ${n.title}${preview ? `: ${preview}` : ""}`); + } + return truncate(lines.join("\n")); +}; + +/** Wrap a resolved block as a system-prompt suffix, or empty when none. */ +export const wrapContextForPrompt = (block: string | null): string => + block + ? `\n\n## Current page context\nUse the following real data about what the user is looking at to ground your answers. If the user asks about something not covered here, say so briefly.\n\n${block}` + : ""; diff --git a/server/src/modules/ai/dto/chat.dto.ts b/server/src/modules/ai/dto/chat.dto.ts index 0192093a..33f13253 100644 --- a/server/src/modules/ai/dto/chat.dto.ts +++ b/server/src/modules/ai/dto/chat.dto.ts @@ -1,9 +1,10 @@ -import { ApiProperty, ApiSchema } from "@nestjs/swagger"; +import { ApiProperty, ApiPropertyOptional, ApiSchema } from "@nestjs/swagger"; import { IsArray, IsEnum, IsOptional, IsString, + IsUUID, Length, ValidateNested, ArrayMaxSize, @@ -15,6 +16,52 @@ export enum ChatRole { ASSISTANT = "assistant", } +/** + * The page/surface the chat was opened from. Drives which owned data the server + * loads and injects as grounding context. + */ +export enum ChatSurface { + ROADMAP = "roadmap", + SIMULATION = "simulation", + AGENDA = "agenda", + NOTES = "notes", + CV = "cv", +} + +/** + * The current page's context — **identifiers only**. The server resolves the + * actual data from these ids under the caller's ownership; a client never sends + * a free-text context blob (no prompt-injection surface). Reuses the #154 + * resolve-owned-context security pattern. + */ +@ApiSchema({ + name: "ChatContextDto", + description: "The current page's surface and the owned id(s) to ground on.", +}) +export class ChatContextDto { + @ApiProperty({ + description: "The page the chat was opened from.", + enum: ChatSurface, + }) + @IsEnum(ChatSurface) + surface: ChatSurface; + + @ApiPropertyOptional({ + description: + "Application the page is about (roadmap, simulation, cv, scoped notes).", + }) + @IsOptional() + @IsUUID() + applicationId?: string; + + @ApiPropertyOptional({ + description: "Interview the page is about (a specific simulation session).", + }) + @IsOptional() + @IsUUID() + interviewId?: string; +} + @ApiSchema({ name: "ChatHistoryItemDto", description: "A single prior turn in the chatbot conversation.", @@ -56,4 +103,14 @@ export class ChatDto { @ValidateNested({ each: true }) @Type(() => ChatHistoryItemDto) history?: ChatHistoryItemDto[]; + + @ApiPropertyOptional({ + description: + "The current page's context (surface + owned ids). The server loads and injects the matching data; omit for a generic, context-free chat.", + type: ChatContextDto, + }) + @IsOptional() + @ValidateNested() + @Type(() => ChatContextDto) + context?: ChatContextDto; } diff --git a/server/talkup-backend-bruno/ai/post chat.bru b/server/talkup-backend-bruno/ai/post chat.bru index f3d996cc..b1b837a6 100644 --- a/server/talkup-backend-bruno/ai/post chat.bru +++ b/server/talkup-backend-bruno/ai/post chat.bru @@ -16,7 +16,7 @@ headers { body:json { { - "message": "How should I structure a behavioral interview answer?", + "message": "Which of these roadmap topics should I prioritise?", "history": [ { "role": "user", @@ -26,7 +26,11 @@ body:json { "role": "assistant", "content": "Great! What role are you preparing for?" } - ] + ], + "context": { + "surface": "roadmap", + "applicationId": "019ac5a6-ada7-7a96-9a38-23819f37ab90" + } } } diff --git a/web/src/components/organisms/chatbot/ChatWidget.spec.tsx b/web/src/components/organisms/chatbot/ChatWidget.spec.tsx index a8890e3d..3f1af451 100644 --- a/web/src/components/organisms/chatbot/ChatWidget.spec.tsx +++ b/web/src/components/organisms/chatbot/ChatWidget.spec.tsx @@ -1,3 +1,4 @@ +import { useChatContext } from '@/hooks/ui/useChatContext'; import { sendChatMessage } from '@/services/ai/http'; import { act, @@ -14,11 +15,21 @@ vi.mock('@/services/ai/http', () => ({ sendChatMessage: vi.fn(), })); +// The widget derives page context from the router; stub the hook so it can +// render outside a RouterProvider and so we can assert what it forwards. +vi.mock('@/hooks/ui/useChatContext', () => ({ + useChatContext: vi.fn(() => undefined), +})); + const mockedSendChatMessage = vi.mocked(sendChatMessage); +const mockedUseChatContext = vi.mocked(useChatContext); describe('ChatWidget', () => { afterEach(() => { vi.clearAllMocks(); + // clearAllMocks resets call history but not implementations set via + // mockReturnValue — restore the default so a context test doesn't leak. + mockedUseChatContext.mockReturnValue(undefined); }); const openChat = () => { @@ -100,6 +111,31 @@ describe('ChatWidget', () => { }); }); + it('forwards the current page context to the API', async () => { + mockedUseChatContext.mockReturnValue({ + surface: 'roadmap', + applicationId: 'app-1', + }); + mockedSendChatMessage.mockResolvedValueOnce({ reply: 'Prioritise K8s.' }); + + render(); + openChat(); + + const input = screen.getByLabelText('Chat message input'); + fireEvent.change(input, { target: { value: 'what should I focus on?' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send message' })); + + await waitFor(() => + expect(screen.getByText('Prioritise K8s.')).toBeInTheDocument(), + ); + + expect(mockedSendChatMessage).toHaveBeenCalledWith({ + message: 'what should I focus on?', + history: [], + context: { surface: 'roadmap', applicationId: 'app-1' }, + }); + }); + it('sends prior turns as history on the second message', async () => { mockedSendChatMessage .mockResolvedValueOnce({ reply: 'First reply.' }) diff --git a/web/src/components/organisms/chatbot/ChatWidget.tsx b/web/src/components/organisms/chatbot/ChatWidget.tsx index a58465a0..2b098a5f 100644 --- a/web/src/components/organisms/chatbot/ChatWidget.tsx +++ b/web/src/components/organisms/chatbot/ChatWidget.tsx @@ -1,6 +1,7 @@ import { Button } from '@/components/atoms/button'; import { Icon } from '@/components/atoms/icon'; import { ChatWindow, Message } from '@/components/organisms/chatbot/ChatWindow'; +import { useChatContext } from '@/hooks/ui/useChatContext'; import { useDragFAB } from '@/hooks/ui/useDragFAB'; import { sendChatMessage } from '@/services/ai/http'; import { ChatHistoryItem } from '@/services/ai/types'; @@ -24,6 +25,9 @@ const getTimestamp = () => export const ChatWidget = () => { const [isOpen, setIsOpen] = useState(false); + // The page the widget is on right now — sent as grounding context so the + // assistant can answer about what the user is looking at. + const chatContext = useChatContext(); // Computed on mount so the welcome timestamp reflects when the chat is first // rendered, not when the module was loaded. const [messages, setMessages] = useState(() => [ @@ -74,7 +78,11 @@ export const ChatWidget = () => { let replyText = ERROR_TEXT; try { - const { reply } = await sendChatMessage({ message: text, history }); + const { reply } = await sendChatMessage({ + message: text, + history, + ...(chatContext ? { context: chatContext } : {}), + }); replyText = reply; } catch { // Fall back to the error message; the details are logged in the service. @@ -90,7 +98,7 @@ export const ChatWidget = () => { }; setMessages((prev) => [...prev, aiMsg]); setIsTyping(false); - }, [inputValue, isTyping, messages]); + }, [inputValue, isTyping, messages, chatContext]); useEffect(() => { mounted.current = true; diff --git a/web/src/config/navigation-contexts.spec.ts b/web/src/config/navigation-contexts.spec.ts index 1edb7344..4eb7c9d6 100644 --- a/web/src/config/navigation-contexts.spec.ts +++ b/web/src/config/navigation-contexts.spec.ts @@ -16,13 +16,14 @@ describe('navigation-contexts', () => { }); it('should contain all expected navigation items', () => { - expect(rootNavigationContext.items).toHaveLength(4); + expect(rootNavigationContext.items).toHaveLength(5); const labels = rootNavigationContext.items.map((item) => item.label); expect(labels).toContain('Applications'); expect(labels).toContain('CV Analysis'); expect(labels).toContain('Agenda'); expect(labels).toContain('Notes'); + expect(labels).toContain('Assistant'); }); it('should have all items visible in navigation', () => { @@ -37,6 +38,7 @@ describe('navigation-contexts', () => { expect(routes).toContain('/cv-analysis'); expect(routes).toContain('/agenda'); expect(routes).toContain('/notes'); + expect(routes).toContain('/ai-chat'); }); it('should have icons for all items', () => { diff --git a/web/src/config/navigation-contexts.ts b/web/src/config/navigation-contexts.ts index c7b52881..d7d92363 100644 --- a/web/src/config/navigation-contexts.ts +++ b/web/src/config/navigation-contexts.ts @@ -35,6 +35,13 @@ export const rootNavigationContext: NavigationContext = { showInNav: true, order: 3, }, + { + to: '/ai-chat', + label: 'Assistant', + icon: 'chat', + showInNav: true, + order: 4, + }, ], }; diff --git a/web/src/hooks/ui/useChatContext.spec.ts b/web/src/hooks/ui/useChatContext.spec.ts new file mode 100644 index 00000000..f2087e27 --- /dev/null +++ b/web/src/hooks/ui/useChatContext.spec.ts @@ -0,0 +1,69 @@ +import { useRouterState } from '@tanstack/react-router'; +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useChatContext } from './useChatContext'; + +vi.mock('@tanstack/react-router', () => ({ + useRouterState: vi.fn(), +})); + +const mockRouter = (pathname: string, search: Record = {}) => { + (useRouterState as any).mockImplementation((opts: any) => + opts.select({ location: { pathname, search } }), + ); +}; + +describe('useChatContext', () => { + beforeEach(() => vi.clearAllMocks()); + + it('maps the roadmap route to a roadmap surface with the application id', () => { + mockRouter('/applications/app-1/roadmap'); + expect(renderHook(() => useChatContext()).result.current).toEqual({ + surface: 'roadmap', + applicationId: 'app-1', + }); + }); + + it('maps the simulations route to a simulation surface', () => { + mockRouter('/applications/app-1/simulations'); + expect(renderHook(() => useChatContext()).result.current).toEqual({ + surface: 'simulation', + applicationId: 'app-1', + }); + }); + + it('maps the agenda route to an agenda surface with no id', () => { + mockRouter('/agenda'); + expect(renderHook(() => useChatContext()).result.current).toEqual({ + surface: 'agenda', + }); + }); + + it('maps notes with an applicationId scope', () => { + mockRouter('/notes', { applicationId: 'app-9' }); + expect(renderHook(() => useChatContext()).result.current).toEqual({ + surface: 'notes', + applicationId: 'app-9', + }); + }); + + it('maps unscoped notes to a notes surface with no id', () => { + mockRouter('/notes'); + expect(renderHook(() => useChatContext()).result.current).toEqual({ + surface: 'notes', + }); + }); + + it('maps the cv-analysis route to a cv surface', () => { + mockRouter('/cv-analysis'); + expect(renderHook(() => useChatContext()).result.current).toEqual({ + surface: 'cv', + }); + }); + + it('returns undefined on a route with no grounding surface', () => { + mockRouter('/settings/profile'); + expect(renderHook(() => useChatContext()).result.current).toBeUndefined(); + }); +}); diff --git a/web/src/hooks/ui/useChatContext.ts b/web/src/hooks/ui/useChatContext.ts new file mode 100644 index 00000000..a64a6296 --- /dev/null +++ b/web/src/hooks/ui/useChatContext.ts @@ -0,0 +1,48 @@ +import type { ChatContext } from '@/services/ai/types'; +import { useRouterState } from '@tanstack/react-router'; + +/** + * Derive the chatbot's page context from the current route so the app-wide + * widget can ground its answers. Returns identifiers only (surface + owned + * ids) — the server resolves the actual data under the user's ownership. + * + * Returns undefined on routes with no grounding surface (the widget then chats + * generically). + */ +export const useChatContext = (): ChatContext | undefined => { + const { pathname, search } = useRouterState({ + select: (s) => ({ + pathname: s.location.pathname, + search: s.location.search as Record, + }), + }); + + // /applications//roadmap|simulations — the id is the second segment. + const appMatch = pathname.match( + /^\/applications\/([^/]+)\/(roadmap|simulations)/, + ); + if (appMatch) { + const applicationId = appMatch[1]; + return appMatch[2] === 'simulations' + ? { surface: 'simulation', applicationId } + : { surface: 'roadmap', applicationId }; + } + + if (pathname.startsWith('/agenda')) { + return { surface: 'agenda' }; + } + + if (pathname.startsWith('/notes')) { + const applicationId = + typeof search.applicationId === 'string' + ? search.applicationId + : undefined; + return { surface: 'notes', ...(applicationId ? { applicationId } : {}) }; + } + + if (pathname.startsWith('/cv-analysis')) { + return { surface: 'cv' }; + } + + return undefined; +}; diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index 3399e736..dc28981a 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -1,3 +1,4 @@ +import { ChatWidget } from '@/components/organisms/chatbot/ChatWidget'; import LandingNav from '@/components/organisms/landing-nav'; import Sidebar from '@/components/organisms/sidebar'; import { useAuth } from '@/contexts/AuthContext'; @@ -65,6 +66,9 @@ const RootComponent = () => {

+ {/* App-wide assistant: floats on every authenticated page and grounds + its answers in the current route's data (#197). */} + diff --git a/web/src/routes/ai-chat.tsx b/web/src/routes/ai-chat.tsx index 71b5f1f5..34c4b4bd 100644 --- a/web/src/routes/ai-chat.tsx +++ b/web/src/routes/ai-chat.tsx @@ -1,11 +1,11 @@ -import { ChatWidget } from '@/components/organisms/chatbot/ChatWidget'; import { createAuthGuard } from '@/utils/auth.guards'; import { createFileRoute } from '@tanstack/react-router'; /** * @route /ai-chat - * @description Entry point for the TalkUp AI chatbot session. - * Renders the floating ChatWidget scoped to this route. + * @description Landing page for the TalkUp AI assistant. The chat widget itself + * is mounted app-wide from the root layout (#197), so it floats on every page; + * this route is the nav entry point that explains it. */ export const Route = createFileRoute('/ai-chat')({ beforeLoad: createAuthGuard('/ai-chat'), @@ -15,15 +15,16 @@ export const Route = createFileRoute('/ai-chat')({ function AiChatPage() { return (
-
+

- TalkUp AI Session + TalkUp AI Assistant

- Click the button in the bottom-right corner to start chatting. + The assistant floats in the bottom-right corner on every page. Open it + anywhere and ask about what you're looking at — your roadmap, a + simulation, your agenda, or your notes.

-
); } diff --git a/web/src/services/ai/types.ts b/web/src/services/ai/types.ts index 2c6ecbb3..3d78705e 100644 --- a/web/src/services/ai/types.ts +++ b/web/src/services/ai/types.ts @@ -90,12 +90,28 @@ export interface ChatHistoryItem { content: string; } +/** The page a chat was opened from. Drives server-side context grounding. */ +export type ChatSurface = 'roadmap' | 'simulation' | 'agenda' | 'notes' | 'cv'; + +/** + * The current page's context — identifiers only. The server resolves the actual + * data from these ids under the caller's ownership; the client never sends a + * data blob. + */ +export interface ChatContext { + surface: ChatSurface; + applicationId?: string; + interviewId?: string; +} + /** Payload sent to the chatbot endpoint. */ export interface ChatRequest { /** The user's message. */ message: string; /** Prior conversation turns, oldest first, excluding the current message. */ history?: ChatHistoryItem[]; + /** The current page's surface + owned id(s), for grounded answers. */ + context?: ChatContext; } /** Response returned by the chatbot endpoint. */ From 0e7972862db68df0b3f4314e86a1711f04baefac Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 03:36:21 +0200 Subject: [PATCH 2/4] bug: keep open-ended agenda events in range queries and gate the chat widget on auth --- .../src/modules/agenda/agenda.service.spec.ts | 24 +++++++++- server/src/modules/agenda/agenda.service.ts | 29 +++++++++--- server/src/modules/ai/chat-context.spec.ts | 47 ++++++++++++++++--- web/src/routes/__root.tsx | 6 ++- 4 files changed, 91 insertions(+), 15 deletions(-) diff --git a/server/src/modules/agenda/agenda.service.spec.ts b/server/src/modules/agenda/agenda.service.spec.ts index 0bd0afb8..ee69504b 100644 --- a/server/src/modules/agenda/agenda.service.spec.ts +++ b/server/src/modules/agenda/agenda.service.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from "@nestjs/testing"; import { getRepositoryToken } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { IsNull, Repository } from "typeorm"; import { InternalServerErrorException, NotFoundException, @@ -219,6 +219,28 @@ describe("AgendaService", () => { expect(result).toEqual([mockEvent]); }); + it("matches open-ended events, scoped to the caller", async () => { + const from = new Date("2025-01-01T00:00:00.000Z"); + const to = new Date("2025-01-08T00:00:00.000Z"); + (mockRepo.find as jest.Mock).mockResolvedValue([]); + + await service.listForRange("user-1", from, to); + + // `end_at` is nullable and `NULL >= from` is never true in SQL, so an + // event with no end time needs its own branch or it silently vanishes + // from every range. + const where = (mockRepo.find as jest.Mock).mock.calls[0][0].where; + expect(Array.isArray(where)).toBe(true); + expect(where).toHaveLength(2); + expect(where[1]).toMatchObject({ + user_id: "user-1", + end_at: IsNull(), + }); + expect( + where.every((w: { user_id: string }) => w.user_id === "user-1"), + ).toBe(true); + }); + it("should log and throw on error", async () => { const from = new Date("2025-01-01T00:00:00.000Z"); const to = new Date("2025-01-08T00:00:00.000Z"); diff --git a/server/src/modules/agenda/agenda.service.ts b/server/src/modules/agenda/agenda.service.ts index 3c141b42..828a929f 100644 --- a/server/src/modules/agenda/agenda.service.ts +++ b/server/src/modules/agenda/agenda.service.ts @@ -6,7 +6,13 @@ import { BadRequestException, } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository, LessThanOrEqual, MoreThanOrEqual } from "typeorm"; +import { + Repository, + LessThanOrEqual, + MoreThanOrEqual, + Between, + IsNull, +} from "typeorm"; import { CreateEventDto } from "./dto/createEvent.dto"; import { UpdateEventDto } from "./dto/updateEvent.dto"; @@ -109,11 +115,22 @@ export class AgendaService { async listForRange(user_id: string, from: Date, to: Date) { try { return await this.repo.find({ - where: { - user_id, - start_at: LessThanOrEqual(to), - end_at: MoreThanOrEqual(from), - }, + // An event overlaps the window when it starts before the end of it and + // has not already finished. `end_at` is nullable, and SQL comparisons + // against NULL are never true — so open-ended events need their own + // branch, otherwise they would silently drop out of every range. + where: [ + { + user_id, + start_at: LessThanOrEqual(to), + end_at: MoreThanOrEqual(from), + }, + { + user_id, + start_at: Between(from, to), + end_at: IsNull(), + }, + ], order: { start_at: "ASC" }, }); } catch (error) { diff --git a/server/src/modules/ai/chat-context.spec.ts b/server/src/modules/ai/chat-context.spec.ts index d236200e..46b0eaaa 100644 --- a/server/src/modules/ai/chat-context.spec.ts +++ b/server/src/modules/ai/chat-context.spec.ts @@ -1,3 +1,5 @@ +import type { RoadmapExtraction } from "@common/utils/groqExtraction"; + import { formatRoadmapContext, formatSimulationContext, @@ -87,17 +89,50 @@ describe("chat-context formatters", () => { /no notes for this application/i, ); expect(formatNotesContext([], "all")).toMatch(/no notes yet/i); + expect(formatNotesContext([], "note")).toMatch(/note is empty/i); + }); + + it("grounds a single open note on its full body, not a preview", () => { + const body = "z".repeat(600); + const out = formatNotesContext( + [{ title: "Datadog prep", content: `

${body}

` }], + "note", + ); + + expect(out).toContain("Datadog prep"); + // The list scope truncates each note at 200 chars; the open note must not. + expect(out).toContain(body); + expect(out).not.toContain("

"); }); }); describe("truncation + wrapping", () => { - it("caps a huge block at the max length", () => { - const many = Array.from({ length: 500 }, (_, i) => ({ - title: `Note ${i}`, - content: "x".repeat(100), - })); - const out = formatNotesContext(many, "all"); + it("caps a block that overruns the max length and marks it truncated", () => { + // The roadmap block is unbounded in topic count, so it is the surface + // that can actually overrun the cap — notes are pre-sliced to 20 short + // previews and never get near it. + const roadmap = { + match_score: 70, + summary: "s", + topics: Array.from({ length: 200 }, (_, i) => ({ + priority: "high", + gap: false, + title: `Topic ${i}`, + rationale: "y".repeat(100), + })), + talking_points: [], + } as unknown as RoadmapExtraction; + + const out = formatRoadmapContext({ job_title: "Dev" }, roadmap); + expect(out.length).toBeLessThanOrEqual(MAX_CHAT_CONTEXT_CHARS + 20); + expect(out).toContain("…(truncated)"); + }); + + it("leaves a block under the cap untouched", () => { + const out = formatNotesContext([{ title: "N", content: "short" }], "all"); + + expect(out).not.toContain("…(truncated)"); }); it("wraps a block under a context heading and returns '' for null", () => { diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index dc28981a..97398237 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -67,8 +67,10 @@ const RootComponent = () => { {/* App-wide assistant: floats on every authenticated page and grounds - its answers in the current route's data (#197). */} - + its answers in the current route's data (#197). Gated on auth — the + app shell also backs the not-found page, which an anonymous visitor + can reach; chatting there would 401 and bounce them to /login. */} + {isAuthenticated && }

From 72c578f56303622f1506b3b4cc4c3ab57c6158b3 Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 03:36:21 +0200 Subject: [PATCH 3/4] feat: ground the chatbot on the open note instead of the whole notes list --- server/src/modules/ai/ai.service.spec.ts | 36 +++++++++++++++++++- server/src/modules/ai/ai.service.ts | 9 +++++ server/src/modules/ai/chat-context.ts | 20 +++++++++-- server/src/modules/ai/dto/chat.dto.ts | 8 +++++ server/talkup-backend-bruno/ai/post chat.bru | 18 ++++++++++ web/src/hooks/ui/useChatContext.spec.ts | 22 ++++++++++-- web/src/hooks/ui/useChatContext.ts | 11 +++--- web/src/services/ai/types.ts | 1 + 8 files changed, 115 insertions(+), 10 deletions(-) diff --git a/server/src/modules/ai/ai.service.spec.ts b/server/src/modules/ai/ai.service.spec.ts index 7acde47f..925d7783 100644 --- a/server/src/modules/ai/ai.service.spec.ts +++ b/server/src/modules/ai/ai.service.spec.ts @@ -119,6 +119,7 @@ describe("AiService", () => { mockNotesService = { findAll: jest.fn(), + findOne: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ @@ -866,10 +867,43 @@ describe("AiService", () => { "user-1", ); - expect(mockAgendaService.listForRange).toHaveBeenCalled(); + // Pin the caller's id: the whole feature rests on grounding only ever + // touching the authenticated user's own rows. + expect(mockAgendaService.listForRange).toHaveBeenCalledWith( + "user-1", + expect.any(Date), + expect.any(Date), + ); expect(systemOf()).toContain("Datadog interview"); }); + it("grounds an open note on that note alone", async () => { + mockNotesService.findOne.mockResolvedValue({ + title: "Datadog prep", + content: "

Ask about the on-call rotation

", + }); + mockGroqCreate.mockResolvedValueOnce({ + choices: [{ message: { content: "ok" } }], + }); + + await service.chat( + { + message: "summarise this note", + context: { surface: "notes", noteId: "note-abc" } as any, + }, + "user-1", + ); + + expect(mockNotesService.findOne).toHaveBeenCalledWith( + "user-1", + "note-abc", + ); + // The open note must win over the list path, or the assistant answers + // about every note except the one on screen. + expect(mockNotesService.findAll).not.toHaveBeenCalled(); + expect(systemOf()).toContain("Ask about the on-call rotation"); + }); + it("degrades to no context (still replies) when resolution throws", async () => { // A foreign/missing application makes the owner-guarded loader throw; // the chat must still answer, just without grounding. diff --git a/server/src/modules/ai/ai.service.ts b/server/src/modules/ai/ai.service.ts index c02c2c94..51810807 100644 --- a/server/src/modules/ai/ai.service.ts +++ b/server/src/modules/ai/ai.service.ts @@ -219,6 +219,15 @@ export class AiService { } case ChatSurface.NOTES: { + // Most specific wins: one open note grounds on itself, an + // application-scoped list on that application, otherwise all notes. + if (context.noteId) { + const note = await this.notesService.findOne( + userId, + context.noteId, + ); + return formatNotesContext([note], "note"); + } const notes = context.applicationId ? await this.notesService.findAll(userId, { applicationId: context.applicationId, diff --git a/server/src/modules/ai/chat-context.ts b/server/src/modules/ai/chat-context.ts index b7d4a626..71e260c3 100644 --- a/server/src/modules/ai/chat-context.ts +++ b/server/src/modules/ai/chat-context.ts @@ -113,16 +113,32 @@ const stripHtml = (html: string | null | undefined): string => .replace(/\s+/g, " ") .trim(); -/** Notes block: the notes in view (titles + short previews). */ +/** + * Notes block. A single open note ("note" scope) is grounded in full — the user + * is looking straight at it and will ask about its body. A list is grounded as + * titles + short previews, since it only needs to support "which note said…". + */ export const formatNotesContext = ( notes: { title: string; content?: string | null }[], - scope: "application" | "all", + scope: "note" | "application" | "all", ): string => { if (!notes.length) { + if (scope === "note") return "The note is empty."; return scope === "application" ? "The user has no notes for this application yet." : "The user has no notes yet."; } + + if (scope === "note") { + const [note] = notes; + const body = stripHtml(note.content); + return truncate( + `The user is reading the note "${note.title}".${ + body ? `\n\nIts content:\n${body}` : "\n\nIt has no content yet." + }`, + ); + } + const lines = [ scope === "application" ? "The user's notes for this application:" diff --git a/server/src/modules/ai/dto/chat.dto.ts b/server/src/modules/ai/dto/chat.dto.ts index 33f13253..10c99497 100644 --- a/server/src/modules/ai/dto/chat.dto.ts +++ b/server/src/modules/ai/dto/chat.dto.ts @@ -60,6 +60,14 @@ export class ChatContextDto { @IsOptional() @IsUUID() interviewId?: string; + + @ApiPropertyOptional({ + description: + "The single note the page is about (the note detail view). Grounds on that note alone rather than the whole list.", + }) + @IsOptional() + @IsUUID() + noteId?: string; } @ApiSchema({ diff --git a/server/talkup-backend-bruno/ai/post chat.bru b/server/talkup-backend-bruno/ai/post chat.bru index b1b837a6..ff4ff667 100644 --- a/server/talkup-backend-bruno/ai/post chat.bru +++ b/server/talkup-backend-bruno/ai/post chat.bru @@ -38,3 +38,21 @@ settings { encodeUrl: true timeout: 0 } + +docs { + Chat with the assistant, optionally grounded in the page the user is on. + + `context` carries **identifiers only** — the server loads the matching rows + under the caller's ownership, so a client can never ground on data it does + not own, nor inject a free-text context blob. + + | surface | id to send | grounds on | + |---|---|---| + | `roadmap` | `applicationId` | the application's preparation roadmap | + | `cv` | `applicationId` | the application's CV + job offer | + | `simulation` | `interviewId` (or `applicationId` for the latest) | the session's score, feedback and transcript | + | `agenda` | — | the user's events over the next 30 days | + | `notes` | `noteId` for one open note, `applicationId` to scope a list, or neither for all | the note body, or the list's titles + previews | + + Omit `context` entirely for a generic, ungrounded chat. +} diff --git a/web/src/hooks/ui/useChatContext.spec.ts b/web/src/hooks/ui/useChatContext.spec.ts index f2087e27..1400ca7a 100644 --- a/web/src/hooks/ui/useChatContext.spec.ts +++ b/web/src/hooks/ui/useChatContext.spec.ts @@ -55,13 +55,29 @@ describe('useChatContext', () => { }); }); - it('maps the cv-analysis route to a cv surface', () => { - mockRouter('/cv-analysis'); + it('maps an open note to a notes surface carrying that note id', () => { + mockRouter('/notes/note-abc'); expect(renderHook(() => useChatContext()).result.current).toEqual({ - surface: 'cv', + surface: 'notes', + noteId: 'note-abc', }); }); + it('grounds an open note on the note, not the list scope', () => { + // The detail route must win over the list's applicationId search param, + // otherwise the assistant answers about every note but the open one. + mockRouter('/notes/note-abc', { applicationId: 'app-9' }); + expect(renderHook(() => useChatContext()).result.current).toEqual({ + surface: 'notes', + noteId: 'note-abc', + }); + }); + + it('returns undefined on cv-analysis (no owned id to ground on)', () => { + mockRouter('/cv-analysis'); + expect(renderHook(() => useChatContext()).result.current).toBeUndefined(); + }); + it('returns undefined on a route with no grounding surface', () => { mockRouter('/settings/profile'); expect(renderHook(() => useChatContext()).result.current).toBeUndefined(); diff --git a/web/src/hooks/ui/useChatContext.ts b/web/src/hooks/ui/useChatContext.ts index a64a6296..c4b1d5b0 100644 --- a/web/src/hooks/ui/useChatContext.ts +++ b/web/src/hooks/ui/useChatContext.ts @@ -32,6 +32,13 @@ export const useChatContext = (): ChatContext | undefined => { return { surface: 'agenda' }; } + // /notes/ — the detail view grounds on that one note. Checked before the + // list route, whose scope comes from a search param instead. + const noteMatch = pathname.match(/^\/notes\/([^/]+)/); + if (noteMatch) { + return { surface: 'notes', noteId: noteMatch[1] }; + } + if (pathname.startsWith('/notes')) { const applicationId = typeof search.applicationId === 'string' @@ -40,9 +47,5 @@ export const useChatContext = (): ChatContext | undefined => { return { surface: 'notes', ...(applicationId ? { applicationId } : {}) }; } - if (pathname.startsWith('/cv-analysis')) { - return { surface: 'cv' }; - } - return undefined; }; diff --git a/web/src/services/ai/types.ts b/web/src/services/ai/types.ts index 3d78705e..fe03f4b3 100644 --- a/web/src/services/ai/types.ts +++ b/web/src/services/ai/types.ts @@ -102,6 +102,7 @@ export interface ChatContext { surface: ChatSurface; applicationId?: string; interviewId?: string; + noteId?: string; } /** Payload sent to the chatbot endpoint. */ From ce8322e4f6fedc2985c830b288fe21651cea7ff9 Mon Sep 17 00:00:00 2001 From: BhuvanArn Date: Wed, 15 Jul 2026 03:52:47 +0200 Subject: [PATCH 4/4] docs: describe end-less agenda events as points, matching the calendar --- server/src/modules/agenda/agenda.service.spec.ts | 5 +++-- server/src/modules/agenda/agenda.service.ts | 13 +++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/server/src/modules/agenda/agenda.service.spec.ts b/server/src/modules/agenda/agenda.service.spec.ts index ee69504b..e00ea676 100644 --- a/server/src/modules/agenda/agenda.service.spec.ts +++ b/server/src/modules/agenda/agenda.service.spec.ts @@ -219,7 +219,7 @@ describe("AgendaService", () => { expect(result).toEqual([mockEvent]); }); - it("matches open-ended events, scoped to the caller", async () => { + it("matches end-less point events, scoped to the caller", async () => { const from = new Date("2025-01-01T00:00:00.000Z"); const to = new Date("2025-01-08T00:00:00.000Z"); (mockRepo.find as jest.Mock).mockResolvedValue([]); @@ -228,7 +228,8 @@ describe("AgendaService", () => { // `end_at` is nullable and `NULL >= from` is never true in SQL, so an // event with no end time needs its own branch or it silently vanishes - // from every range. + // from every range. It is a point at `start_at`, so that branch matches + // on the start falling inside the window. const where = (mockRepo.find as jest.Mock).mock.calls[0][0].where; expect(Array.isArray(where)).toBe(true); expect(where).toHaveLength(2); diff --git a/server/src/modules/agenda/agenda.service.ts b/server/src/modules/agenda/agenda.service.ts index 828a929f..2ae01515 100644 --- a/server/src/modules/agenda/agenda.service.ts +++ b/server/src/modules/agenda/agenda.service.ts @@ -115,10 +115,15 @@ export class AgendaService { async listForRange(user_id: string, from: Date, to: Date) { try { return await this.repo.find({ - // An event overlaps the window when it starts before the end of it and - // has not already finished. `end_at` is nullable, and SQL comparisons - // against NULL are never true — so open-ended events need their own - // branch, otherwise they would silently drop out of every range. + // A timed event overlaps the window when it starts before the window + // ends and has not already finished. + // + // `end_at` is nullable, and every SQL comparison against NULL is NULL + // (never true), so such rows can't satisfy the timed branch and need + // their own — without it they drop out of every range silently. An + // event with no end is a point in time at `start_at` (the calendar + // renders it that way, see web useCalendarStore `end: … : start_at`), + // so it belongs to the window when it *starts* inside it. where: [ { user_id,