fix: keep birthdays and show Shamsi dates

Imported customers keep their birthday, and assistant text plus remaining calendars use the Jalali date.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-13 18:04:53 +03:30
co-authored by Cursor
parent 5f4cc47c41
commit cf326d1ed9
34 changed files with 637 additions and 105 deletions
+14 -12
View File
@@ -32,7 +32,7 @@ import {
type PersonImportSource,
type UserRole,
} from '@mento/shared';
import { startOfJalaliMonth, toJalali } from '../common/jalali';
import { formatTehranJalali, rewriteGregorianDates, startOfJalaliMonth, toJalali } from '../common/jalali';
import { AiService } from './ai.service';
import {
classifyCountQuestion,
@@ -164,6 +164,7 @@ export class AiAskService {
Person and company "by source" lines are authoritative. If the user names a source (دستی، اکسل، دیدار، ته‌حساب، گوشی، فرم، مونتیفای), use that source count only, never the grand total.
Lead stage and lead source lines are authoritative the same way.
Period stats use Asia/Tehran calendar: today, yesterday, this_week (Saturday–Friday), this_month (current Jalali month).
Every date you write must be Shamsi/Jalali, copied from jalali dates in the context, like ۱۴۰۴/۰۷/۲۱. Never use Gregorian month names (ژانویه، سپتامبر، September) or ISO dates.
Workspace facts and Period stats are complete and authoritative for counts.
You cannot create, update, or delete records. Never say a customer, lead, or activity was created.
Answer ONLY from the CRM context for CRM facts. Never say a listed count is unavailable. If a requested filter is not in the facts, say that filtered count is not available instead of substituting another total.
@@ -185,7 +186,7 @@ Write the answer in Persian, concise, with exact numbers and names when present.
.filter((item): item is CatalogItem => Boolean(item))
.slice(0, 4);
const answer = String(raw.answer ?? '').trim().slice(0, 2000);
const answer = rewriteGregorianDates(String(raw.answer ?? '').trim()).slice(0, 2000);
return {
answer: answer || 'با اطلاعات فعلی پرونده چیزی برای پاسخ پیدا نشد.',
sources,
@@ -206,12 +207,13 @@ Rules:
- Mention durable memory, last contact, open deals, or next follow-up when present.
- cta is one concrete next step, starting with «${brand.suggestionPrefixFa}».
- ctaKind is "call" if a phone follow-up is best, otherwise "activity".
- Do not invent facts.`,
- Do not invent facts.
- Every date must be Shamsi/Jalali like ۱۴۰۴/۰۷/۲۱. Never write Gregorian month names such as سپتامبر or September.`,
`Customer briefing context:\n${context}`,
);
const text = String(raw.text ?? '').trim().slice(0, 700);
const cta = String(raw.cta ?? '').trim().slice(0, 120);
const text = rewriteGregorianDates(String(raw.text ?? '').trim()).slice(0, 700);
const cta = rewriteGregorianDates(String(raw.cta ?? '').trim()).slice(0, 120);
const ctaKind = raw.ctaKind === 'activity' ? 'activity' : 'call';
return {
text: text || 'هنوز دادهٔ کافی برای خلاصه هوشمند این مشتری نیست.',
@@ -925,7 +927,7 @@ Rules:
.filter(Boolean);
const lines: string[] = [
`Today (Asia/Tehran): ${dayKey} | now=${now.toISOString()}`,
`Today (Asia/Tehran, Jalali): ${formatTehranJalali(now)} | gregorian_key=${dayKey}`,
`Periods: yesterday=${periods.yesterday.key} | this_week=${periods.week.key} (Sat–Fri) | this_month=${periods.month.key} (Jalali)`,
'Workspace facts (authoritative):',
`User: ${user?.fullName ?? '—'} | email: ${user?.email ?? '—'} | mobile: ${user?.mobile ?? '—'} | role in current environment: ${roleLabel(actor.role)}`,
@@ -983,7 +985,7 @@ Rules:
const label = `${p.firstName} ${p.lastName}`.trim();
catalog.push({ type: 'person', id: p.id, label });
lines.push(
`Person ${p.id} ${label} created=${p.createdAt.toISOString()} phone=${p.mobile || p.phone || '—'} email=${p.email ?? '—'}`,
`Person ${p.id} ${label} created_jalali=${formatTehranJalali(p.createdAt)} phone=${p.mobile || p.phone || '—'} email=${p.email ?? '—'}`,
);
}
}
@@ -1002,7 +1004,7 @@ Rules:
label: clip(note.title || note.body, 60),
});
lines.push(
`Note ${note.id} (${note.createdAt.toISOString()})${who ? ` [${who}]` : ''}: ${clip(note.title ? `${note.title} — ${note.body}` : note.body)}`,
`Note ${note.id} (jalali=${formatTehranJalali(note.createdAt)})${who ? ` [${who}]` : ''}: ${clip(note.title ? `${note.title} — ${note.body}` : note.body)}`,
);
}
for (const activity of activities) {
@@ -1011,7 +1013,7 @@ Rules:
: '';
catalog.push({ type: 'activity', id: activity.id, label: activity.title });
lines.push(
`Activity ${activity.id} type=${activity.type} status=${activity.status} due=${activity.dueAt?.toISOString() ?? '—'} title=${activity.title}${who ? ` person=${who}` : ''}${activity.description ? ` | ${clip(activity.description, 160)}` : ''}`,
`Activity ${activity.id} type=${activity.type} status=${activity.status} due_jalali=${activity.dueAt ? formatTehranJalali(activity.dueAt) : '—'} title=${activity.title}${who ? ` person=${who}` : ''}${activity.description ? ` | ${clip(activity.description, 160)}` : ''}`,
);
}
for (const deal of deals) {
@@ -1079,7 +1081,7 @@ Rules:
if (!person.firstName) missing.push('نام');
return {
intent,
summary: (raw.summary ?? '').trim() || `ساخت مشتری ${person.firstName} ${person.lastName}`.trim(),
summary: rewriteGregorianDates((raw.summary ?? '').trim()) || `ساخت مشتری ${person.firstName} ${person.lastName}`.trim(),
missing,
canConfirm: missing.length === 0,
person,
@@ -1096,7 +1098,7 @@ Rules:
if (!activity.title) missing.push('عنوان فعالیت');
return {
intent,
summary: (raw.summary ?? '').trim() || `ثبت فعالیت «${activity.title}»`,
summary: rewriteGregorianDates((raw.summary ?? '').trim()) || `ثبت فعالیت «${activity.title}»`,
missing,
canConfirm: missing.length === 0,
person: null,
@@ -1107,7 +1109,7 @@ Rules:
return {
intent: 'none',
summary:
(raw.summary ?? '').trim() ||
rewriteGregorianDates((raw.summary ?? '').trim()) ||
'در این مرحله فقط ساخت مشتری یا ثبت فعالیت از روی صوت انجام می‌شود.',
missing: [],
canConfirm: false,
+10 -7
View File
@@ -4,6 +4,7 @@ import {
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { rewriteGregorianDates } from '../common/jalali';
export type NoteAiExtract = {
nextAction: {
@@ -44,6 +45,7 @@ Rules:
- dueAt: ISO 8601 date-time if a deadline is mentioned; otherwise null.
- memoryItems: durable facts about the customer (preferences, budget, decisions, family, objections) — NOT tasks.
- Keep text in Persian when the note is Persian.
- If summary mentions a date, write it Shamsi/Jalali like ۱۴۰۴/۰۷/۲۱, never Gregorian month names such as سپتامبر.
- Do not invent facts not supported by the note.
- Maximum 5 memory items.`;
@@ -76,7 +78,8 @@ Rules:
- summary: 3–8 concise Persian sentences covering purpose, customer needs, outcomes, and commitments.
- nextAction: one short Persian follow-up if needed; otherwise null.
- Do not invent facts not in the transcript.
- Keep names, numbers, and amounts accurate.`;
- Keep names, numbers, and amounts accurate.
- Dates in summary and nextAction must be Shamsi/Jalali like ۱۴۰۴/۰۷/۲۱, never Gregorian month names such as سپتامبر.`;
const userParts = [
input.personName ? `Customer: ${input.personName}` : null,
@@ -89,12 +92,12 @@ Rules:
nextAction?: string | null;
}>(system, userParts.join('\n'));
const summary = String(raw.summary ?? '').trim().slice(0, 4000);
const summary = rewriteGregorianDates(String(raw.summary ?? '').trim()).slice(0, 4000);
if (!summary) {
throw new BadRequestException('خلاصه تماس خالی بود');
}
const nextAction = raw.nextAction
? String(raw.nextAction).trim().slice(0, 500) || null
? rewriteGregorianDates(String(raw.nextAction).trim()).slice(0, 500) || null
: null;
return { summary, nextAction };
}
@@ -169,9 +172,9 @@ Rules:
const nextAction =
raw.nextAction && typeof raw.nextAction === 'object'
? {
title: String(raw.nextAction.title ?? '').trim().slice(0, 200),
title: rewriteGregorianDates(String(raw.nextAction.title ?? '').trim()).slice(0, 200),
description: raw.nextAction.description
? String(raw.nextAction.description).trim().slice(0, 2000)
? rewriteGregorianDates(String(raw.nextAction.description).trim()).slice(0, 2000)
: undefined,
dueAt: raw.nextAction.dueAt
? String(raw.nextAction.dueAt).trim()
@@ -182,7 +185,7 @@ Rules:
const memoryItems = Array.isArray(raw.memoryItems)
? raw.memoryItems
.map((item) => ({
content: String(item?.content ?? '').trim().slice(0, 500),
content: rewriteGregorianDates(String(item?.content ?? '').trim()).slice(0, 500),
category: item?.category
? String(item.category).trim().slice(0, 40)
: undefined,
@@ -197,7 +200,7 @@ Rules:
return {
nextAction: cleanedNext,
memoryItems,
summary: raw.summary ? String(raw.summary).trim().slice(0, 500) : undefined,
summary: raw.summary ? rewriteGregorianDates(String(raw.summary).trim()).slice(0, 500) : undefined,
};
}
}