Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5433/bootcamp_starter"
REDIS_URL="redis://localhost:6380"
MAILPIT_URL="http://localhost:8025"
APP_URL="http://localhost:3000"

# In-app instructor assistant (POST /chat). Without a key the route returns 503.
ANTHROPIC_API_KEY=""
# Optional model override; defaults to claude-sonnet-5.
ANTHROPIC_MODEL="claude-sonnet-5"
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.112.1",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { StockModule } from './stock/stock.module';
import { InquiriesModule } from './inquiries/inquiries.module';
import { MyInquiriesModule } from './my-inquiries/my-inquiries.module';
import { StatsModule } from './stats/stats.module';
import { ChatModule } from './chat/chat.module';
import { AuditModule } from './audit/audit.module';
import { ProfileModule } from './profile/profile.module';
import { DatabaseModule } from './database/database.module';
Expand All @@ -42,6 +43,7 @@ import { DatabaseModule } from './database/database.module';
InquiriesModule,
MyInquiriesModule,
StatsModule,
ChatModule,
AuditModule,
ProfileModule,
],
Expand Down
83 changes: 83 additions & 0 deletions apps/api/src/chat/chat.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { User } from '@repo/db';

// Model + generation config. Overridable via env so the model can be swapped
// without a code change. Sonnet 5 is the default — strong instruction-following
// (which keeps the assistant in-scope) at a lower cost than Opus for a
// high-touch in-app helper.
// eslint-disable-next-line turbo/no-undeclared-env-vars -- loaded at runtime from apps/api/.env via ConfigModule, not Turbo-managed
export const CHAT_MODEL = process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-5';

// A single reply is short; cap output so cost stays bounded and the
// non-streaming request never risks an HTTP timeout.
export const CHAT_MAX_TOKENS = 1024;

// Hard stop on the tool-use loop. Each pass is at most one model round-trip;
// the assistant only needs a couple of data lookups to answer, so this is a
// runaway guard, not a real limit.
export const CHAT_MAX_TOOL_ITERATIONS = 5;

// Wall-clock budget for a single assistant turn — the whole tool-use loop and
// any SDK retries, enforced with one AbortSignal shared across every model
// call. /chat is synchronous, so without this a hung or slow completion would
// ride the SDK's 10-minute per-request default (and, multiplied across loop
// iterations, longer still) and blow past upstream proxy/LB limits as an opaque
// 504. When the deadline fires, the in-flight request aborts and the error
// flows into our ServiceUnavailableException fallback.
export const CHAT_TIMEOUT_MS = 30_000;

// The instructor persona and guardrails. Static across every request (good for
// prompt caching); the caller's own identity is appended per-request by
// `buildSystemPrompt` so the assistant knows who it is helping and what it may
// look up.
const BASE_SYSTEM_PROMPT = `You are the in-app instructor assistant for MedFind, a Pharmacy Inquiry & Stock Management Platform.
Your only job is to help the logged-in user understand and use this platform effectively.

You can:
- Explain what any page, feature, or metric on this platform means.
- Walk users through how to complete tasks (e.g. adding a user, pharmacy, or medicine).
- Answer questions about the user's own data, using ONLY the live data returned by your tools — never guess or make up numbers.
- Explain platform concepts like user roles, statuses (pending/active/suspended/inactive), catalog coverage, barcode coverage, low stock, near-expiry batches, and open inquiries.

You must NOT:
- Answer questions unrelated to this platform (general knowledge, unrelated coding, medical advice, other topics).
- Reveal data belonging to other organizations, pharmacies, or branches the current user cannot see.
- Make up features, pages, or data that do not exist in this system.
- Provide direct database/SQL access or expose raw query results — only summarize the structured data your tools return.

Rules for data questions:
- Always call the appropriate tool to fetch live numbers before answering; never state a figure from memory.
- Your tools are already scoped to the current user's permissions — you cannot request another tenant's data, so there is no need to ask for IDs.
- If a tool returns an error (e.g. the account is not attached to a pharmacy or branch), explain that plainly instead of inventing a value.

If asked something outside your scope, respond briefly and redirect the user back to platform-related help.
Keep answers concise and friendly, and prefer short paragraphs or bullet lists.`;

// Human-readable descriptions of each role, so the assistant can explain the
// caller's own permissions accurately.
const ROLE_DESCRIPTIONS: Record<User['role'], string> = {
SUPER_ADMIN: 'platform super administrator (sees platform-wide data)',
PHARMACY_ADMIN:
'pharmacy administrator (manages one pharmacy and its branches)',
PHARMACY_MANAGER: 'branch manager (manages a single branch)',
PHARMACY_EMPLOYEE: 'branch staff member',
STOCK_MANAGER: 'stock manager (handles stock batches for a branch)',
INQUIRY_OFFICER: 'inquiry officer (handles client inquiries for a branch)',
CLIENT:
'client (a regular platform user browsing pharmacies and asking inquiries)',
};

/**
* Compose the system prompt for a specific caller. The identity block lets the
* assistant greet the user and reason about which data it is allowed to look
* up, without the model ever having to ask for (or be trusted with) tenant IDs.
*/
export function buildSystemPrompt(actor: User): string {
const role = ROLE_DESCRIPTIONS[actor.role] ?? actor.role;
return `${BASE_SYSTEM_PROMPT}

--- Current user ---
Name: ${actor.firstName} ${actor.lastName}
Role: ${actor.role} — ${role}
Account status: ${actor.status}
Use the data tools available to you to answer questions about this user's own metrics.`;
}
28 changes: 28 additions & 0 deletions apps/api/src/chat/chat.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import {
chatRequestSchema,
type ChatRequest,
type ChatResponse,
} from '@repo/contracts';
import type { User } from '@repo/db';
import { CurrentUser } from '../auth/decorators';
import { ZodValidationPipe } from '../common/pipes';
import { ChatService } from './chat.service';

// The in-app instructor assistant. The global AuthGuard requires a valid
// session; there is no @Roles guard because every authenticated user may ask
// for help. All data access is scoped to the session actor inside the service,
// never to anything in the request body.
@Controller('chat')
export class ChatController {
constructor(private readonly chatService: ChatService) {}

@Post()
@HttpCode(HttpStatus.OK)
chat(
@CurrentUser() actor: User,
@Body(new ZodValidationPipe(chatRequestSchema)) body: ChatRequest,
): Promise<ChatResponse> {
return this.chatService.chat(actor, body.messages);
}
}
13 changes: 13 additions & 0 deletions apps/api/src/chat/chat.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { StatsModule } from '../stats/stats.module';
import { ChatController } from './chat.controller';
import { ChatService } from './chat.service';

// Reuses StatsService (via StatsModule) as the assistant's read-only,
// tenant-scoped data source rather than touching Prisma directly.
@Module({
imports: [StatsModule],
controllers: [ChatController],
providers: [ChatService],
})
export class ChatModule {}
204 changes: 204 additions & 0 deletions apps/api/src/chat/chat.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import {
Injectable,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
import type { ChatMessage, ChatResponse } from '@repo/contracts';
import type { User } from '@repo/db';
import { StatsService } from '../stats/stats.service';
import {
buildSystemPrompt,
CHAT_MAX_TOKENS,
CHAT_MAX_TOOL_ITERATIONS,
CHAT_MODEL,
CHAT_TIMEOUT_MS,
} from './chat.constants';

// A data tool the assistant may call. `roles` gates which callers even see the
// tool (mirroring the platform's own authorization); `run` derives everything
// from the session actor, so the model can never widen its own scope. The tool
// takes no input for exactly that reason — there is nothing for the model to
// supply that could point at another tenant.
interface DataTool {
name: string;
description: string;
roles: User['role'][];
run: (actor: User) => Promise<unknown>;
}

@Injectable()
export class ChatService {
private readonly logger = new Logger(ChatService.name);
// Constructed lazily so the API still boots without a key (the /chat route
// then fails cleanly per-request instead of crashing startup).
private readonly client: Anthropic | null;

constructor(private readonly stats: StatsService) {
// eslint-disable-next-line turbo/no-undeclared-env-vars -- loaded at runtime from apps/api/.env via ConfigModule, not Turbo-managed
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
this.logger.warn(
'ANTHROPIC_API_KEY is not set — the /chat assistant will be unavailable.',
);
}
this.client = apiKey ? new Anthropic({ apiKey }) : null;
}

// The full tool catalogue. Each entry maps to an existing, tenant-scoped
// StatsService method; the role list matches what StatsController already
// exposes, so the assistant can never read data the caller couldn't fetch
// through the normal API.
private readonly tools: DataTool[] = [
{
name: 'get_platform_stats',
description:
'Platform-wide totals for the super-admin overview: user counts by status, pharmacies (and how many have a branch), branches, and the medicine catalog (total, priced, barcoded, new this week).',
roles: ['SUPER_ADMIN'],
run: () => this.stats.platform(),
},
{
name: 'get_pharmacy_stats',
description:
"The current user's pharmacy dashboard: branch count, employee count, total open inquiries and low-stock medicines, plus a per-branch breakdown (staff, open inquiries, low stock, near-expiry batches).",
roles: ['PHARMACY_ADMIN'],
run: (actor) => this.stats.pharmacy(actor),
},
{
name: 'get_branch_stats',
description:
"The current user's own branch dashboard: low-stock medicine count, near-expiry batch count, open inquiry count, and a short recent-activity feed.",
roles: ['PHARMACY_MANAGER', 'PHARMACY_EMPLOYEE'],
run: (actor) => this.stats.branch(actor),
},
];

async chat(actor: User, messages: ChatMessage[]): Promise<ChatResponse> {
if (!this.client) {
throw new ServiceUnavailableException(
'The assistant is not configured. Please try again later.',
);
}

// Only expose the tools this caller is permitted to use. A tool the model
// can't see is a tool it can't call.
const allowed = this.tools.filter((tool) =>
tool.roles.includes(actor.role),
);
const toolByName = new Map(allowed.map((tool) => [tool.name, tool]));
const toolDefs: Anthropic.Tool[] = allowed.map((tool) => ({
name: tool.name,
description: tool.description,
input_schema: {
type: 'object',
properties: {},
additionalProperties: false,
},
}));

const system = buildSystemPrompt(actor);
const conversation: Anthropic.MessageParam[] = messages.map((message) => ({
role: message.role,
content: message.content,
}));

// One wall-clock deadline for the whole turn. Sharing a single signal across
// every model call means the loop and the SDK's own retries can never
// collectively outlive it; when it fires, the in-flight request aborts and
// the error is handled by the catch below.
const signal = AbortSignal.timeout(CHAT_TIMEOUT_MS);

try {
for (let i = 0; i < CHAT_MAX_TOOL_ITERATIONS; i++) {
const response = await this.client.messages.create(
{
model: CHAT_MODEL,
max_tokens: CHAT_MAX_TOKENS,
system,
messages: conversation,
tools: toolDefs.length > 0 ? toolDefs : undefined,
},
{ signal },
);

if (response.stop_reason !== 'tool_use') {
return { reply: this.extractText(response) };
}

// Resolve every tool call the model made this turn, then feed all the
// results back in a single user turn (required for parallel tool use).
conversation.push({ role: 'assistant', content: response.content });
const toolResults = await Promise.all(
response.content
.filter(
(block): block is Anthropic.ToolUseBlock =>
block.type === 'tool_use',
)
.map((block) => this.runTool(actor, toolByName, block)),
);
conversation.push({ role: 'user', content: toolResults });
}

this.logger.warn(
`Chat tool loop hit the ${CHAT_MAX_TOOL_ITERATIONS}-iteration cap for user ${actor.id}.`,
);
return {
reply:
"I wasn't able to finish looking that up. Could you rephrase or narrow the question?",
};
} catch (error) {
this.logger.error('Chat completion failed.', error);
throw new ServiceUnavailableException(
'The assistant is temporarily unavailable. Please try again.',
);
}
}

// Execute one tool call. Failures (e.g. the actor has no branch) are returned
// to the model as an error result rather than thrown, so it can explain the
// problem to the user in natural language.
private async runTool(
actor: User,
toolByName: Map<string, DataTool>,
block: Anthropic.ToolUseBlock,
): Promise<Anthropic.ToolResultBlockParam> {
const tool = toolByName.get(block.name);
if (!tool) {
return {
type: 'tool_result',
tool_use_id: block.id,
content: `Unknown or unavailable tool: ${block.name}`,
is_error: true,
};
}

try {
const data = await tool.run(actor);
return {
type: 'tool_result',
tool_use_id: block.id,
content: JSON.stringify(data),
};
} catch (error) {
const message =
error instanceof Error ? error.message : 'Failed to fetch data.';
return {
type: 'tool_result',
tool_use_id: block.id,
content: message,
is_error: true,
};
}
}

private extractText(response: Anthropic.Message): string {
const text = response.content
.filter((block): block is Anthropic.TextBlock => block.type === 'text')
.map((block) => block.text)
.join('\n')
.trim();
return text.length > 0
? text
: "I'm not sure how to help with that. Try asking about a MedFind page, feature, or your own metrics.";
}
}
1 change: 1 addition & 0 deletions apps/api/src/stats/stats.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ import { StatsService } from './stats.service';
@Module({
controllers: [StatsController],
providers: [StatsService],
exports: [StatsService],
})
export class StatsModule {}
2 changes: 2 additions & 0 deletions apps/web/app/(authenticated)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { TopNavbar } from '@/components/top-navbar';
import { ChatWidget } from '@/components/chat/chat-widget';

export default function DashboardLayout({
children,
Expand All @@ -22,6 +23,7 @@ export default function DashboardLayout({
<main className="min-h-0 flex-1 overflow-y-auto bg-gray-50 p-6 [&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-white hover:[&::-webkit-scrollbar-thumb]:bg-gray-400">
{children}
</main>
<ChatWidget />
</SidebarInset>
</SidebarProvider>
);
Expand Down
Loading
Loading