feat: team module, multi-stage Dockerfile, doctor utils, AI config overhaul

- Team module: POST /api/team/members (in-place employee creation with
  temp password + Redis cache), PUT /api/team/members/:id, GET temp
  password endpoint. Uses signUpInWorkspace — no email invites.
- Dockerfile: rewritten as multi-stage build (builder + runtime) so
  native modules compile for target arch. Fixes darwin→linux crash.
- .dockerignore: exclude dist, node_modules, .env, .git, data/
- package-lock.json: regenerated against public npmjs.org (was
  pointing at localhost:4873 Verdaccio — broke docker builds)
- Doctor utils: shared DOCTOR_VISIT_SLOTS_FRAGMENT + normalizeDoctors
  helper for visit-slot-aware queries across 6 consumers
- AI config: full admin CRUD (GET/PUT/POST reset), workspace-scoped
  setup-state with workspace ID isolation, AI prompt defaults overhaul
- Agent config: camelCase field fix for SDK-synced workspaces
- Session service: workspace-scoped Redis key prefixing for setup state
- Recordings/supervisor/widget services: updated to use doctor-utils
  shared fragments instead of inline visitingHours queries

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 08:37:58 +05:30
parent eacfce6970
commit 695f119c2b
25 changed files with 2756 additions and 1936 deletions

View File

@@ -158,48 +158,28 @@ export class WidgetChatService {
const kb = await this.getKnowledgeBase();
// Branch context flips the tool-usage rules: no branch = must call
// pick_branch first; branch set = always pass it to branch-aware tools.
// pick_branch first; branch set = always pass it to branch-aware
// tools. We pre-render this block since the structure is dynamic
// and the template just slots it in via {{branchContext}}.
const branchContext = selectedBranch
? [
`CURRENT BRANCH: ${selectedBranch}`,
`The visitor is interested in the ${selectedBranch} branch. You MUST pass branch="${selectedBranch}"`,
'to list_departments, show_clinic_timings, show_doctors, and show_doctor_slots every time.',
]
].join('\n')
: [
'BRANCH STATUS: NOT SET',
'The visitor has not picked a branch yet. Before calling list_departments, show_clinic_timings,',
'show_doctors, or show_doctor_slots, you MUST call pick_branch first so the visitor can choose.',
'Only skip this if the user asks a pure general question that does not need branch-specific data.',
];
].join('\n');
return [
`You are a helpful, concise assistant for ${init.brand.name}.`,
`You are chatting with a website visitor named ${userName}.`,
'',
...branchContext,
'',
'TOOL USAGE RULES (STRICT):',
'- When the user asks about departments, call list_departments and DO NOT also list departments in prose.',
'- When they ask about clinic timings, visiting hours, or "when is X open", call show_clinic_timings.',
'- When they ask about doctors in a department, call show_doctors and DO NOT also list doctors in prose.',
'- When they ask about a specific doctor\'s availability or want to book with them, call show_doctor_slots.',
'- When the conversation is trending toward booking, call suggest_booking.',
'- After calling a tool, DO NOT restate its contents in prose. At most write a single short sentence',
' (under 15 words) framing the widget, or no text at all. The widget already shows the data.',
'- If you are about to write a bulleted or numbered list of departments, doctors, hours, or slots,',
' STOP and call the appropriate tool instead.',
'- NEVER use markdown formatting (no **bold**, no *italic*, no bullet syntax). Plain text only in',
' non-tool replies.',
'- NEVER invent or mention specific dates in prose or tool inputs. The server owns "today".',
' If the visitor asks about a future date, tell them to use the Book tab\'s date picker.',
'',
'OTHER RULES:',
'- Answer other questions (directions, general info) concisely in prose.',
'- If you do not know something, say so and suggest they call the hospital.',
'- Never quote prices. No medical advice. For clinical questions, defer to a doctor.',
'',
kb,
].join('\n');
return this.aiConfig.renderPrompt('widgetChat', {
hospitalName: init.brand.name,
userName,
branchContext,
knowledgeBase: kb,
});
}
// Streams the assistant reply as an async iterable of UIMessageChunk-shaped
@@ -213,13 +193,15 @@ export class WidgetChatService {
const platform = this.platform;
const widgetSvc = this.widget;
// Small helper: does a doctor's clinic match the branch filter?
// Case-insensitive substring match so "Indiranagar" matches
// "Indiranagar Clinic" etc.
// Branch-matching now uses the doctor's full `clinics` array
// (NormalizedDoctor) since one doctor can visit multiple
// clinics under the post-rework data model. doctorMatchesBranch
// returns true if ANY of their visit-slot clinics matches.
const matchesBranch = (d: any, branch: string | undefined): boolean => {
if (!branch) return true;
const clinicName = String(d.clinic?.clinicName ?? '').toLowerCase();
return clinicName.includes(branch.toLowerCase());
const needle = branch.toLowerCase();
const clinics: Array<{ clinicName: string }> = d.clinics ?? [];
return clinics.some((c) => c.clinicName.toLowerCase().includes(needle));
};
const tools = {
@@ -229,23 +211,37 @@ export class WidgetChatService {
inputSchema: z.object({}),
execute: async () => {
const doctors = await widgetSvc.getDoctors();
const byBranch = new Map<string, { doctorCount: number; departments: Set<string> }>();
// Branches come from the union of all doctors'
// visit-slot clinics. Each (clinic × doctor) pair
// counts once toward that branch's doctor count;
// we use a Set on doctor ids to avoid double-
// counting the same doctor against the same branch
// when they have multiple slots there.
const byBranch = new Map<
string,
{ doctorIds: Set<string>; departments: Set<string> }
>();
for (const d of doctors) {
const name = d.clinic?.clinicName?.trim();
if (!name) continue;
if (!byBranch.has(name)) {
byBranch.set(name, { doctorCount: 0, departments: new Set() });
for (const c of d.clinics ?? []) {
const name = c.clinicName?.trim();
if (!name) continue;
if (!byBranch.has(name)) {
byBranch.set(name, {
doctorIds: new Set(),
departments: new Set(),
});
}
const entry = byBranch.get(name)!;
if (d.id) entry.doctorIds.add(d.id);
if (d.department) entry.departments.add(String(d.department).replace(/_/g, ' '));
}
const entry = byBranch.get(name)!;
entry.doctorCount += 1;
if (d.department) entry.departments.add(String(d.department).replace(/_/g, ' '));
}
return {
branches: Array.from(byBranch.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, { doctorCount, departments }]) => ({
.map(([name, { doctorIds, departments }]) => ({
name,
doctorCount,
doctorCount: doctorIds.size,
departmentCount: departments.size,
})),
};