Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9a410b2
test(api): Enqueue AI job in thought-map dialogue tests
shettydev Mar 30, 2026
d6815ae
feat(api): Enqueue AI initial question
shettydev Mar 30, 2026
4e198d4
feat(web): Handle async thought map dialogue start
shettydev Mar 30, 2026
ba7b388
feat(web, components): Show generating state when creating dialogue
shettydev Mar 30, 2026
9baec25
test(web): Use layout spacing in thought-map tests
shettydev Mar 31, 2026
ac0249d
refactor(web): Persist and center ghost nodes
shettydev Mar 31, 2026
b75fb08
test(web): Use ghost offset in thought-map
shettydev Mar 31, 2026
6f82263
fix(web): Use wider offset for ghost nodes
shettydev Mar 31, 2026
e4337e3
test(api): Expand thought-map dialogue tests
shettydev Mar 31, 2026
e2f77da
refactor(api): Use BYOK for thought map initial question
shettydev Mar 31, 2026
dddfbd2
fix(api): Skip empty user message for OpenRouter
shettydev Mar 31, 2026
b69484b
refactor(web): Refine dialogue panel loading conditions
shettydev Mar 31, 2026
ee563b2
refactor(api,web): Lint error fixes
shettydev Mar 31, 2026
0cd5de3
feat(web): Persist ghost node position on accept
shettydev Mar 31, 2026
2bffe39
feat(web): Enhance QuestionNode animations
shettydev Mar 31, 2026
eb448f8
test(api): Accept omitted title in ConvertCanvasDto
shettydev Mar 31, 2026
a4aefc9
test(api): add tests for thought map initial question processing
shettydev Mar 31, 2026
07d6123
docs(api): update Swagger docs for Thought Map dialogue start
shettydev Mar 31, 2026
3aed79b
refactor(web): harden thought map dialogue and store logic
shettydev Mar 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions packages/mukti-api/src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ import { ConfigService } from '@nestjs/config';
import { AuthGuard } from '@nestjs/passport';
import { ApiTags } from '@nestjs/swagger';

import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Public } from '../../common/decorators/public.decorator';
import { SkipEnvelope } from '../../common/decorators/skip-envelope.decorator';
import { WaitlistService } from '../waitlist/waitlist.service';
import {
AuthResponseDto,
ChangePasswordDto,
Expand All @@ -34,11 +38,6 @@ import {
UserResponseDto,
VerifyEmailDto,
} from './dto';

import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Public } from '../../common/decorators/public.decorator';
import { SkipEnvelope } from '../../common/decorators/skip-envelope.decorator';
import { WaitlistService } from '../waitlist/waitlist.service';
import {
ApiChangePassword,
ApiForgotPassword,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,8 @@ export class DialogueAIService {

/**
* Builds the messages array for the OpenRouter API.
* Skips the user message when empty (e.g. initial question generation
* where the system prompt already contains all instructions).
*/
private buildMessages(
systemPrompt: string,
Expand All @@ -416,11 +418,13 @@ export class DialogueAIService {
});
}

// Add current user message
messages.push({
content: userMessage,
role: 'user',
});
// Add current user message (skip when empty — e.g. initial question generation)
if (userMessage) {
messages.push({
content: userMessage,
role: 'user',
});
}

return messages;
}
Expand Down
48 changes: 48 additions & 0 deletions packages/mukti-api/src/modules/dialogue/utils/prompt-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,51 @@ Guidelines:
- Keep responses concise (2-4 sentences typically)`;
}

/**
* Builds a system prompt for generating the AI-powered initial Socratic question
* for a ThoughtMap node dialogue.
*
* Extends the base ThoughtMap system prompt with:
* - Sibling node context (labels of nodes sharing the same parent)
* - Explicit instruction to produce a single opening question
*
* @param node - The node context being discussed
* @param mapTitle - The Thought Map's root topic/title
* @param technique - The Socratic technique to apply
* @param siblingLabels - Labels of sibling nodes (same parent) for branch context
* @returns The constructed system prompt for initial question generation
*/
export function buildThoughtMapInitialQuestionPrompt(
node: NodeContext,
mapTitle: string,
technique: SocraticTechnique,
siblingLabels: string[],
): string {
const basePrompt = buildThoughtMapSystemPrompt(node, mapTitle, technique);

const siblingContext =
siblingLabels.length > 0
? `\nOther nodes at this level: ${siblingLabels.map((l) => `"${l}"`).join(', ')}\n`
: '';

return `${basePrompt}
${siblingContext}
---
TASK: Generate an opening Socratic question for this node.
---

This is the very first message in the dialogue — there is no prior conversation.

Your question should:
- Be tailored to the node's type (${node.nodeType}) and its position in the thinking map
- Consider the broader topic context ("${mapTitle}")
- Invite the user to begin exploring this node's subject matter
- Be warm and engaging while intellectually challenging
- Follow the ${technique} technique guidelines above

Respond with ONLY the opening question — no preamble, no meta-commentary.`;
}

/**
* Builds the system prompt for Thought Map node dialogue.
* Substitutes map context (title + node summary) instead of canvas problem structure.
Expand Down Expand Up @@ -248,6 +293,9 @@ export function generateInitialQuestion(
* @param nodeType - The ThoughtMap node type
* @param nodeLabel - The node's content/label
* @returns An initial question tailored to the node type
*
* @deprecated Use `buildThoughtMapInitialQuestionPrompt` + AI generation instead.
* Kept as a fallback for when the AI service is unavailable.
*/
export function generateThoughtMapInitialQuestion(
nodeType: NodeType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ describe('ThoughtMapDialogueController', () => {
};

const mockAiPolicyService = {
getDefaultModel: jest.fn().mockReturnValue('openai/gpt-5-mini'),
resolveEffectiveModel: jest.fn().mockResolvedValue('resolved-model'),
};

Expand All @@ -68,6 +69,7 @@ describe('ThoughtMapDialogueController', () => {
const mockThoughtNodeModel = {
countDocuments: jest.fn(),
findOne: jest.fn(),
updateOne: jest.fn().mockResolvedValue({}),
};

beforeEach(async () => {
Expand Down Expand Up @@ -153,6 +155,8 @@ describe('ThoughtMapDialogueController', () => {

mockThoughtMapService.findMapById.mockResolvedValue({ _id: mapId });
mockThoughtNodeModel.findOne.mockResolvedValue({
depth: 1,
fromSuggestion: false,
label: 'Node label',
type: 'thought',
});
Expand All @@ -171,10 +175,13 @@ describe('ThoughtMapDialogueController', () => {
);

expect(mockDialogueService.addMessage).not.toHaveBeenCalled();
expect(result.initialQuestion.content).toBe('Existing question');
expect(result).toHaveProperty(
'initialQuestion.content',
'Existing question',
);
});

it('creates the initial question when the dialogue is empty', async () => {
it('enqueues an AI job using server key when dialogue is empty and user has no BYOK', async () => {
const mapId = new Types.ObjectId().toString();
const dialogue = {
_id: new Types.ObjectId(),
Expand All @@ -184,49 +191,199 @@ describe('ThoughtMapDialogueController', () => {
nodeLabel: 'Node label',
nodeType: 'thought',
};
const updatedDialogue = {
...dialogue,
messageCount: 1,
};
const createdMessage = {

mockThoughtMapService.findMapById.mockResolvedValue({ _id: mapId });
mockThoughtNodeModel.findOne.mockResolvedValue({
depth: 1,
fromSuggestion: false,
label: 'Node label',
type: 'thought',
});
mockThoughtMapDialogueQueueService.getOrCreateMapDialogue.mockResolvedValue(
dialogue,
);
mockDialogueService.getMessages.mockResolvedValue({
messages: [],
pagination: { total: 0 },
});
setUserRecord({
openRouterApiKeyEncrypted: undefined,
preferences: { activeModel: 'saved-model' },
});
mockThoughtMapDialogueQueueService.enqueueMapNodeRequest.mockResolvedValue({
jobId: 'job-1',
position: 1,
});

const result = await controller.startDialogue(
mapId,
'thought-0',
mockUser as any,
);

// Should NOT create a message synchronously — the queue worker does that
expect(mockDialogueService.addMessage).not.toHaveBeenCalled();

// Should resolve model via policy service (no requestedModel for initial question)
expect(mockAiPolicyService.resolveEffectiveModel).toHaveBeenCalledWith({
hasByok: false,
userActiveModel: 'saved-model',
validationApiKey: 'server-openrouter-key',
});

// Should enqueue a job with isInitialQuestion flag and resolved model
expect(
mockThoughtMapDialogueQueueService.enqueueMapNodeRequest,
).toHaveBeenCalledWith(
mockUser._id,
mapId,
'thought-0',
'thought',
'Node label',
1, // depth
false, // fromSuggestion
0, // siblings (no parentId)
undefined, // parentType
'', // empty message
'free', // subscriptionTier
'resolved-model', // effectiveModel from resolveEffectiveModel
false, // not BYOK
true, // isInitialQuestion
);

// Should return async response shape with jobId
expect('jobId' in result).toBe(true);
if ('jobId' in result) {
expect(result.jobId).toBe('job-1');
expect(result.position).toBe(1);
}
});

it('enqueues an AI job using BYOK when dialogue is empty and user has an API key', async () => {
const mapId = new Types.ObjectId().toString();
const dialogue = {
_id: new Types.ObjectId(),
content:
'You\'ve noted: "Node label". What led you to this thought? Is this an observation, an assumption, or a conclusion?',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
dialogueId: dialogue._id,
metadata: { model: 'system' },
role: 'assistant',
sequence: 0,
messageCount: 0,
nodeId: 'thought-0',
nodeLabel: 'Node label',
nodeType: 'thought',
};

mockThoughtMapService.findMapById.mockResolvedValue({ _id: mapId });
mockThoughtNodeModel.findOne.mockResolvedValue({
depth: 1,
fromSuggestion: false,
label: 'Node label',
type: 'thought',
});
mockThoughtMapDialogueQueueService.getOrCreateMapDialogue
.mockResolvedValueOnce(dialogue)
.mockResolvedValueOnce(updatedDialogue);
mockThoughtMapDialogueQueueService.getOrCreateMapDialogue.mockResolvedValue(
dialogue,
);
mockDialogueService.getMessages.mockResolvedValue({
messages: [],
pagination: { total: 0 },
});
mockDialogueService.addMessage.mockResolvedValue(createdMessage);
setUserRecord({
openRouterApiKeyEncrypted: 'encrypted-key',
preferences: { activeModel: 'saved-model' },
});
mockThoughtMapDialogueQueueService.enqueueMapNodeRequest.mockResolvedValue({
jobId: 'job-2',
position: 1,
});

const result = await controller.startDialogue(
await controller.startDialogue(mapId, 'thought-0', mockUser as any);

expect(mockAiSecretsService.decryptString).toHaveBeenCalledWith(
'encrypted-key',
);
expect(mockAiPolicyService.resolveEffectiveModel).toHaveBeenCalledWith({
hasByok: true,
userActiveModel: 'saved-model',
validationApiKey: 'decrypted-key',
});
expect(
mockThoughtMapDialogueQueueService.enqueueMapNodeRequest,
).toHaveBeenCalledWith(
expect.anything(),
mapId,
'thought-0',
mockUser as any,
'thought',
'Node label',
1,
false,
0,
undefined,
'',
'free',
'resolved-model',
true, // BYOK
true, // isInitialQuestion
);
});

it('throws when server key is missing and user has no BYOK on startDialogue', async () => {
const mapId = new Types.ObjectId().toString();
const dialogue = {
_id: new Types.ObjectId(),
messageCount: 0,
nodeId: 'thought-0',
nodeLabel: 'Node label',
nodeType: 'thought',
};

mockThoughtMapService.findMapById.mockResolvedValue({ _id: mapId });
mockThoughtNodeModel.findOne.mockResolvedValue({
depth: 0,
fromSuggestion: false,
label: 'Node label',
type: 'thought',
});
mockThoughtMapDialogueQueueService.getOrCreateMapDialogue.mockResolvedValue(
dialogue,
);
mockDialogueService.getMessages.mockResolvedValue({
messages: [],
pagination: { total: 0 },
});
setUserRecord({ openRouterApiKeyEncrypted: undefined, preferences: {} });
mockConfigService.get.mockReturnValueOnce('');

expect(mockDialogueService.addMessage).toHaveBeenCalledWith(
dialogue._id,
'assistant',
createdMessage.content,
{ model: 'system' },
await expect(
controller.startDialogue(mapId, 'thought-0', mockUser as any),
).rejects.toThrow('OPENROUTER_API_KEY not configured');
});

it('throws when the user record is missing during startDialogue', async () => {
const mapId = new Types.ObjectId().toString();
const dialogue = {
_id: new Types.ObjectId(),
messageCount: 0,
nodeId: 'thought-0',
nodeLabel: 'Node label',
nodeType: 'thought',
};

mockThoughtMapService.findMapById.mockResolvedValue({ _id: mapId });
mockThoughtNodeModel.findOne.mockResolvedValue({
depth: 0,
fromSuggestion: false,
label: 'Node label',
type: 'thought',
});
mockThoughtMapDialogueQueueService.getOrCreateMapDialogue.mockResolvedValue(
dialogue,
);
expect(result.dialogue.messageCount).toBe(1);
expect(result.initialQuestion.content).toBe(createdMessage.content);
mockDialogueService.getMessages.mockResolvedValue({
messages: [],
pagination: { total: 0 },
});
mockUserModel.lean.mockResolvedValue(null);

await expect(
controller.startDialogue(mapId, 'thought-0', mockUser as any),
).rejects.toThrow('User not found');
});

it('returns empty pagination when no dialogue exists yet', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,8 @@ describe('ThoughtMap DTO validation', () => {
).not.toHaveLength(0);
});

it('surfaces the current ConvertCanvasDto optional-title contract mismatch', () => {
expect(validate(ConvertCanvasDto, {})).not.toHaveLength(0);
it('accepts ConvertCanvasDto when title is omitted', () => {
expect(validate(ConvertCanvasDto, {})).toHaveLength(0);
});

it('accepts a non-empty ConvertCanvasDto title when provided', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ export class ConvertCanvasDto {
maxLength: 500,
required: false,
})
@IsOptional()
@IsNotEmpty()
@IsOptional()
@IsString()
title?: string;
}
Loading
Loading