2026-09-03 14:20:55 +03:30
import { Injectable , NotFoundException } from '@nestjs/common' ;
import type {
2026-09-23 15:49:44 +03:30
ActivityType ,
2026-09-03 14:20:55 +03:30
AskMentoResponse ,
AskMentoSourceDto ,
PersonAiSummaryDto ,
2026-09-23 15:49:44 +03:30
VoiceActivityDraft ,
VoiceCommandDraft ,
VoicePersonDraft ,
2026-09-03 14:20:55 +03:30
} from '@mento/shared' ;
import { PrismaService } from '../prisma/prisma.service' ;
import {
type Actor ,
assigneeWhere ,
noteAccessWhere ,
ownerWhere ,
2026-09-21 19:24:39 +03:30
ticketAccessWhere ,
2026-09-03 14:20:55 +03:30
} from '../common/record-scope' ;
2026-09-21 16:58:49 +03:30
import {
BILLING_PLAN_LABELS ,
BUSINESS_FIELD_LABELS ,
USER_ROLE_LABELS ,
brand ,
brandSuggestionFa ,
isBusinessField ,
type BillingPlanCode ,
type UserRole ,
} from '@mento/shared' ;
2026-09-21 19:24:39 +03:30
import { startOfJalaliMonth , toJalali } from '../common/jalali' ;
2026-09-03 14:20:55 +03:30
import { AiService } from './ai.service' ;
type CatalogItem = AskMentoSourceDto ;
2026-09-21 19:24:39 +03:30
type PeriodRange = { key : string ; label : string ; start : Date ; end : Date };
type PeriodCounts = {
today : number ;
yesterday : number ;
week : number ;
month : number ;
};
2026-09-03 14:20:55 +03:30
function clip ( value : string | null | undefined , max = 280 ) {
const text = ( value ?? '' ). replace ( /\s+/g , ' ' ). trim ();
if ( ! text ) return '' ;
return text . length > max ? ` ${ text . slice ( 0 , max ) } …` : text ;
}
2026-09-21 16:58:49 +03:30
function roleLabel ( role : string ) {
return USER_ROLE_LABELS [ role as UserRole ] ?? role ;
}
function planLabel ( code : string ) {
return BILLING_PLAN_LABELS [ code as BillingPlanCode ] ?? code ;
}
2026-09-21 19:24:39 +03:30
function addDaysToDayKey ( dayKey : string , days : number ) : string {
const [ y , m , d ] = dayKey . split ( '-' ). map ( Number );
const dt = new Date ( Date . UTC ( y ! , m ! - 1 , d ! ));
dt . setUTCDate ( dt . getUTCDate () + days );
return dt . toISOString (). slice ( 0 , 10 );
}
function dayBoundsFromKey ( dayKey : string ) {
return {
start : new Date ( ` ${ dayKey } T00:00:00+03:30` ),
end : new Date ( ` ${ dayKey } T23:59:59.999+03:30` ),
};
}
/** بازههای تقویمی ایران: امروز، دیروز، این هفته (شنبه تا جمعه)، این ماه شمسی */
function tehranPeriods ( now = new Date ()) {
const dayKey = new Intl . DateTimeFormat ( 'en-CA' , {
timeZone : 'Asia/Tehran' ,
year : 'numeric' ,
month : '2-digit' ,
day : '2-digit' ,
}). format ( now );
const weekday = new Intl . DateTimeFormat ( 'en-US' , {
timeZone : 'Asia/Tehran' ,
weekday : 'short' ,
}). format ( now );
const sinceSaturday : Record < string , number > = {
Sat : 0 ,
Sun : 1 ,
Mon : 2 ,
Tue : 3 ,
Wed : 4 ,
Thu : 5 ,
Fri : 6 ,
};
const weekOffset = sinceSaturday [ weekday ] ?? 0 ;
const yesterdayKey = addDaysToDayKey ( dayKey , - 1 );
const weekStartKey = addDaysToDayKey ( dayKey , - weekOffset );
const weekEndKey = addDaysToDayKey ( weekStartKey , 6 );
const today = { key : dayKey , label : 'today' , ... dayBoundsFromKey ( dayKey ) };
const yesterday = {
key : yesterdayKey ,
label : 'yesterday' ,
... dayBoundsFromKey ( yesterdayKey ),
};
const week : PeriodRange = {
key : ` ${ weekStartKey } .. ${ weekEndKey } ` ,
label : 'this_week' ,
start : dayBoundsFromKey ( weekStartKey ). start ,
end : dayBoundsFromKey ( weekEndKey ). end ,
};
const { jy , jm } = (() => {
const [ gy , gm , gd ] = dayKey . split ( '-' ). map ( Number );
return toJalali ( gy ! , gm ! , gd ! );
})();
const monthStart = startOfJalaliMonth ( jy , jm );
const month : PeriodRange = {
key : ` ${ jy } - ${ String ( jm ). padStart ( 2 , '0' ) } ` ,
label : 'this_jalali_month' ,
start : monthStart ,
end : now ,
};
return { now , dayKey , today , yesterday , week , month };
}
function formatPeriodCounts ( labelFa : string , counts : PeriodCounts ) {
return ` ${ labelFa } : today= ${ counts . today } | yesterday= ${ counts . yesterday } | this_week= ${ counts . week } | this_month= ${ counts . month } ` ;
}
2026-09-03 14:20:55 +03:30
@Injectable ()
export class AiAskService {
constructor (
private readonly prisma : PrismaService ,
private readonly ai : AiService ,
) {}
async ask (
actor : Actor ,
question : string ,
personId? : string ,
2026-09-30 16:30:41 +03:30
chatContext? : string ,
2026-09-03 14:20:55 +03:30
) : Promise < AskMentoResponse > {
const { context , catalog } = await this . buildContext ( actor , personId );
2026-09-30 16:30:41 +03:30
const chatBlock = String ( chatContext || '' )
. trim ()
. slice ( 0 , 3500 );
2026-09-03 14:20:55 +03:30
const raw = await this . ai . completeJson < {
answer? : string ;
sources? : Array < { type ?: string ; id? : string ; label? : string } > ;
} > (
2026-09-21 16:58:49 +03:30
`You are ${ brand . name } , a Persian CRM assistant for this user's account.
2026-09-21 19:24:39 +03:30
"محیط" means a workspace/organization the user belongs to.
"مشتری" / "مشتری جدید" means a Person (contact) record.
Period stats use Asia/Tehran calendar: today, yesterday, this_week (Saturday– Friday), this_month (current Jalali month).
Workspace facts and Period stats are complete and authoritative for counts of customers, companies, activities, deals, won deals, invoices, quotes, products, notes, and tickets.
2026-09-30 16:30:41 +03:30
Answer ONLY from the CRM context for CRM facts. Never say a listed count is unavailable.
If a "Recent conversation" block is provided, use it only to understand the user's question and tone; do not invent CRM facts from chat alone.
2026-09-03 14:20:55 +03:30
Do not invent customers, amounts, dates, or promises.
Return JSON: { "answer": string, "sources": [{ "type": "person"|"note"|"activity"|"deal"|"memory", "id": string, "label": string }] }
2026-09-21 19:24:39 +03:30
Use at most 4 sources, and only ids that appear in the context. Workspace/period facts do not need sources; when naming customers, prefer person sources.
2026-09-21 16:58:49 +03:30
Write the answer in Persian, concise, with exact numbers and names when present.` ,
2026-09-30 16:30:41 +03:30
`Question: \ n ${ question . trim () } \ n \ nCRM context: \ n ${ context }${
chatBlock
? ` \ n \ nRecent conversation (for context only, not CRM facts): \ n ${ chatBlock } `
: ''
} ` ,
2026-09-03 14:20:55 +03:30
);
const allowed = new Map ( catalog . map (( item ) => [ item . id , item ]));
const sources = ( Array . isArray ( raw . sources ) ? raw . sources : [])
. map (( item ) => allowed . get ( String ( item ? . id ?? '' )))
. filter (( item ) : item is CatalogItem => Boolean ( item ))
. slice ( 0 , 4 );
const answer = String ( raw . answer ?? '' ). trim (). slice ( 0 , 2000 );
return {
answer : answer || 'با اطلاعات فعلی پرونده چیزی برای پاسخ پیدا نشد.' ,
sources ,
};
}
async summarizePerson ( actor : Actor , personId : string ) : Promise < PersonAiSummaryDto > {
const { context } = await this . buildContext ( actor , personId );
const raw = await this . ai . completeJson < {
text? : string ;
cta? : string ;
ctaKind? : string ;
} > (
`You write a short live briefing for a CRM customer card in Persian.
Return JSON: { "text": string, "cta": string, "ctaKind": "call"|"activity" }
Rules:
- 2 to 4 short sentences from the context only.
- Mention durable memory, last contact, open deals, or next follow-up when present.
2026-09-07 11:49:54 +03:30
- cta is one concrete next step, starting with « ${ brand . suggestionPrefixFa } ».
2026-09-03 14:20:55 +03:30
- ctaKind is "call" if a phone follow-up is best, otherwise "activity".
- Do not invent facts.` ,
`Customer briefing context: \ n ${ context } ` ,
);
const text = String ( raw . text ?? '' ). trim (). slice ( 0 , 700 );
const cta = String ( raw . cta ?? '' ). trim (). slice ( 0 , 120 );
const ctaKind = raw . ctaKind === 'activity' ? 'activity' : 'call' ;
return {
text : text || 'هنوز دادهٔ کافی برای خلاصه هوشمند این مشتری نیست.' ,
2026-09-07 11:49:54 +03:30
cta : cta || brandSuggestionFa ( 'امروز با مشتری تماس بگیر' ),
2026-09-03 14:20:55 +03:30
ctaKind ,
};
}
2026-09-21 19:24:39 +03:30
private async countInPeriods (
count : ( start : Date , end : Date ) => Promise < number >,
periods : ReturnType < typeof tehranPeriods >,
) : Promise < PeriodCounts > {
const [ today , yesterday , week , month ] = await Promise . all ([
count ( periods . today . start , periods . today . end ),
count ( periods . yesterday . start , periods . yesterday . end ),
count ( periods . week . start , periods . week . end ),
count ( periods . month . start , periods . month . end ),
]);
return { today , yesterday , week , month };
}
2026-09-03 14:20:55 +03:30
private async buildContext ( actor : Actor , personId? : string ) {
if ( personId ) {
const person = await this . prisma . person . findFirst ({
where : {
id : personId ,
organizationId : actor.organizationId ,
... ownerWhere ( actor ),
},
include : { company : { select : { name : true } } },
});
if ( ! person ) throw new NotFoundException ( 'شخص پیدا نشد' );
}
2026-09-21 19:24:39 +03:30
const periods = tehranPeriods ();
const { now , dayKey } = periods ;
const orgId = actor . organizationId ;
2026-10-06 15:04:46 +03:30
const dealScope = ownerWhere ( actor , 'deals' );
2026-09-21 16:58:49 +03:30
const activityScope = assigneeWhere ( actor );
const personScope = ownerWhere ( actor );
2026-09-21 19:24:39 +03:30
const companyScope = ownerWhere ( actor );
2026-10-06 15:04:46 +03:30
const invoiceScope = ownerWhere ( actor , 'finance' );
const quoteScope = ownerWhere ( actor , 'finance' );
2026-09-21 19:24:39 +03:30
const ticketScope = ticketAccessWhere ( actor );
const createdBetween =
( start : Date , end : Date ) => ({ createdAt : { gte : start , lte : end } });
2026-09-21 16:58:49 +03:30
const [
org ,
user ,
memberships ,
ownerBilling ,
2026-09-21 19:24:39 +03:30
teamCount ,
peopleTotal ,
companiesTotal ,
productsTotal ,
2026-09-21 16:58:49 +03:30
openDealCount ,
staleDealCount ,
overdueActivityCount ,
2026-09-21 19:24:39 +03:30
activitiesDueToday ,
peoplePeriods ,
companyPeriods ,
activityPeriods ,
dealCreatedPeriods ,
dealWonPeriods ,
invoicePeriods ,
quotePeriods ,
productPeriods ,
notePeriods ,
ticketPeriods ,
peopleToday ,
recentPeople ,
2026-09-21 16:58:49 +03:30
notes ,
activities ,
deals ,
memories ,
] = await Promise . all ([
2026-09-03 16:45:59 +03:30
this . prisma . organization . findUnique ({
2026-09-21 19:24:39 +03:30
where : { id : orgId },
2026-09-03 16:45:59 +03:30
select : { name : true , businessField : true },
}),
2026-09-21 16:58:49 +03:30
this . prisma . user . findUnique ({
where : { id : actor.userId },
select : { fullName : true , email : true , mobile : true },
}),
this . prisma . membership . findMany ({
where : {
userId : actor.userId ,
isActive : true ,
organization : { isActive : true },
},
select : {
role : true ,
organizationId : true ,
organization : { select : { name : true } },
},
orderBy : { createdAt : 'asc' },
}),
this . prisma . billingAccount . findFirst ({
where : {
owner : {
memberships : {
some : {
2026-09-21 19:24:39 +03:30
organizationId : orgId ,
2026-09-21 16:58:49 +03:30
role : 'owner' ,
isActive : true ,
},
},
},
},
select : {
planCode : true ,
status : true ,
expiresAt : true ,
maxOrganizations : true ,
},
}),
2026-09-21 19:24:39 +03:30
this . prisma . membership . count ({
where : { organizationId : orgId , isActive : true },
}),
2026-09-21 16:58:49 +03:30
this . prisma . person . count ({
2026-09-21 19:24:39 +03:30
where : { organizationId : orgId , ... personScope },
}),
this . prisma . company . count ({
where : { organizationId : orgId , ... companyScope },
}),
this . prisma . product . count ({
where : { organizationId : orgId },
}),
this . prisma . deal . count ({
where : { organizationId : orgId , status : 'open' , ... dealScope },
2026-09-21 16:58:49 +03:30
}),
this . prisma . deal . count ({
where : {
2026-09-21 19:24:39 +03:30
organizationId : orgId ,
2026-09-21 16:58:49 +03:30
status : 'open' ,
... dealScope ,
OR : [{ isStale : true }, { isRotten : true }],
},
}),
this . prisma . activity . count ({
where : {
2026-09-21 19:24:39 +03:30
organizationId : orgId ,
2026-09-21 16:58:49 +03:30
... activityScope ,
status : 'open' ,
dueAt : { lt : now },
},
}),
this . prisma . activity . count ({
where : {
2026-09-21 19:24:39 +03:30
organizationId : orgId ,
2026-09-21 16:58:49 +03:30
... activityScope ,
status : 'open' ,
2026-09-21 19:24:39 +03:30
dueAt : { gte : periods.today.start , lte : periods.today.end },
2026-09-21 16:58:49 +03:30
},
}),
2026-09-21 19:24:39 +03:30
this . countInPeriods (
( start , end ) =>
this . prisma . person . count ({
where : {
organizationId : orgId ,
... personScope ,
... createdBetween ( start , end ),
},
}),
periods ,
),
this . countInPeriods (
( start , end ) =>
this . prisma . company . count ({
where : {
organizationId : orgId ,
... companyScope ,
... createdBetween ( start , end ),
},
}),
periods ,
),
this . countInPeriods (
( start , end ) =>
this . prisma . activity . count ({
where : {
organizationId : orgId ,
... activityScope ,
... createdBetween ( start , end ),
},
}),
periods ,
),
this . countInPeriods (
( start , end ) =>
this . prisma . deal . count ({
where : {
organizationId : orgId ,
... dealScope ,
... createdBetween ( start , end ),
},
}),
periods ,
),
this . countInPeriods (
( start , end ) =>
this . prisma . deal . count ({
where : {
organizationId : orgId ,
... dealScope ,
status : 'won' ,
updatedAt : { gte : start , lte : end },
},
}),
periods ,
),
this . countInPeriods (
( start , end ) =>
this . prisma . invoice . count ({
where : {
organizationId : orgId ,
... invoiceScope ,
... createdBetween ( start , end ),
},
}),
periods ,
),
this . countInPeriods (
( start , end ) =>
this . prisma . quote . count ({
where : {
organizationId : orgId ,
... quoteScope ,
... createdBetween ( start , end ),
},
}),
periods ,
),
this . countInPeriods (
( start , end ) =>
this . prisma . product . count ({
where : {
organizationId : orgId ,
... createdBetween ( start , end ),
},
}),
periods ,
),
this . countInPeriods (
( start , end ) =>
this . prisma . note . count ({
where : {
organizationId : orgId ,
... noteAccessWhere ( actor ),
... createdBetween ( start , end ),
},
}),
periods ,
),
this . countInPeriods (
( start , end ) =>
this . prisma . ticket . count ({
where : {
organizationId : orgId ,
... ticketScope ,
... createdBetween ( start , end ),
},
}),
periods ,
),
this . prisma . person . findMany ({
where : {
organizationId : orgId ,
... personScope ,
createdAt : { gte : periods.today.start , lte : periods.today.end },
},
select : {
id : true ,
firstName : true ,
lastName : true ,
mobile : true ,
phone : true ,
email : true ,
createdAt : true ,
},
orderBy : { createdAt : 'desc' },
take : 20 ,
}),
this . prisma . person . findMany ({
where : { organizationId : orgId , ... personScope },
select : {
id : true ,
firstName : true ,
lastName : true ,
mobile : true ,
phone : true ,
email : true ,
createdAt : true ,
},
orderBy : { createdAt : 'desc' },
take : 8 ,
2026-09-21 16:58:49 +03:30
}),
2026-09-03 14:20:55 +03:30
this . prisma . note . findMany ({
where : {
2026-09-21 19:24:39 +03:30
organizationId : orgId ,
2026-09-03 14:20:55 +03:30
... noteAccessWhere ( actor ),
...( personId ? { personId } : {}),
},
select : {
id : true ,
title : true ,
body : true ,
createdAt : true ,
person : { select : { firstName : true , lastName : true } },
},
orderBy : { createdAt : 'desc' },
take : personId ? 15 : 8 ,
}),
this . prisma . activity . findMany ({
where : {
2026-09-21 19:24:39 +03:30
organizationId : orgId ,
2026-09-03 14:20:55 +03:30
... assigneeWhere ( actor ),
...( personId ? { personId } : {}),
},
select : {
id : true ,
type : true ,
status : true ,
title : true ,
description : true ,
dueAt : true ,
person : { select : { firstName : true , lastName : true } },
},
orderBy : { updatedAt : 'desc' },
take : personId ? 15 : 10 ,
}),
this . prisma . deal . findMany ({
where : {
2026-09-21 19:24:39 +03:30
organizationId : orgId ,
2026-10-06 15:04:46 +03:30
... ownerWhere ( actor , 'deals' ),
2026-09-03 14:20:55 +03:30
...( personId ? { personId } : { status : 'open' }),
},
select : {
id : true ,
title : true ,
amount : true ,
currency : true ,
status : true ,
isStale : true ,
isRotten : true ,
person : { select : { firstName : true , lastName : true } },
stage : { select : { name : true } },
},
orderBy : { updatedAt : 'desc' },
take : 10 ,
}),
personId
? this . prisma . memoryItem . findMany ({
2026-09-21 19:24:39 +03:30
where : { organizationId : orgId , personId },
2026-09-03 14:20:55 +03:30
select : { id : true , content : true , updatedAt : true },
orderBy : { updatedAt : 'desc' },
take : 8 ,
})
: Promise . resolve ([]),
]);
const catalog : CatalogItem [] = [];
2026-09-03 16:45:59 +03:30
const field =
org ? . businessField && isBusinessField ( org . businessField )
? BUSINESS_FIELD_LABELS [ org . businessField ]
: 'نامشخص' ;
2026-09-21 16:58:49 +03:30
const envLines = memberships . map (( row ) => {
2026-09-21 19:24:39 +03:30
const current = row . organizationId === orgId ? ' [current]' : '' ;
2026-09-21 16:58:49 +03:30
return ` ${ row . organization . name }${ current } (role= ${ roleLabel ( row . role ) } )` ;
});
const billingDays = ownerBilling
? Math . max (
0 ,
Math . ceil (
( ownerBilling . expiresAt . getTime () - now . getTime ()) /
( 24 * 60 * 60 * 1000 ),
),
)
: null ;
2026-09-21 19:24:39 +03:30
const peopleTodayNames = peopleToday
. map (( p ) => ` ${ p . firstName } ${ p . lastName } ` . trim ())
. filter ( Boolean );
2026-09-03 14:20:55 +03:30
const lines : string [] = [
2026-09-21 19:24:39 +03:30
`Today (Asia/Tehran): ${ dayKey } | now= ${ now . toISOString () } ` ,
`Periods: yesterday= ${ periods . yesterday . key } | this_week= ${ periods . week . key } (Sat– Fri) | this_month= ${ periods . month . key } (Jalali)` ,
2026-09-21 16:58:49 +03:30
'Workspace facts (authoritative):' ,
`User: ${ user ? . fullName ?? '—' } | email: ${ user ? . email ?? '—' } | mobile: ${ user ? . mobile ?? '—' } | role in current environment: ${ roleLabel ( actor . role ) } ` ,
`Environments (محیط) count: ${ memberships . length } ` ,
`Environments: ${ envLines . join ( ' | ' ) || 'none' } ` ,
2026-09-21 19:24:39 +03:30
`Current environment: ${ org ? . name ?? '—' } | business field: ${ field } | team members: ${ teamCount } ` ,
`Totals: people= ${ peopleTotal } | companies= ${ companiesTotal } | products= ${ productsTotal } | open deals= ${ openDealCount } | at-risk deals= ${ staleDealCount } | overdue activities= ${ overdueActivityCount } | activities due today= ${ activitiesDueToday } ` ,
'Period stats (created/registered unless noted):' ,
formatPeriodCounts ( 'People / مشتریان جدید' , peoplePeriods ),
peopleTodayNames . length
? `New customers today names: ${ peopleTodayNames . join ( '، ' ) } `
: 'New customers today names: (none)' ,
formatPeriodCounts ( 'Companies / شرکتها' , companyPeriods ),
formatPeriodCounts ( 'Activities / فعالیتها (created)' , activityPeriods ),
formatPeriodCounts ( 'Deals / معاملات (created)' , dealCreatedPeriods ),
formatPeriodCounts ( 'Won deals / معاملات برنده (updatedAt while won)' , dealWonPeriods ),
formatPeriodCounts ( 'Invoices / فاکتورها' , invoicePeriods ),
formatPeriodCounts ( 'Quotes / پیشفاکتورها' , quotePeriods ),
formatPeriodCounts ( 'Products / محصولات' , productPeriods ),
formatPeriodCounts ( 'Notes / یادداشتها' , notePeriods ),
formatPeriodCounts ( 'Tickets / تیکتها' , ticketPeriods ),
2026-09-03 14:20:55 +03:30
];
2026-09-21 16:58:49 +03:30
if ( ownerBilling && billingDays != null ) {
lines . push (
`Subscription of current environment owner: ${ planLabel ( ownerBilling . planCode ) } | status= ${ ownerBilling . status } | days remaining= ${ billingDays } | max environments= ${ ownerBilling . maxOrganizations } ` ,
);
}
lines . push (
2026-09-21 19:24:39 +03:30
personId
? `Focus personId: ${ personId } `
: 'Scope: current user records in the current environment' ,
2026-09-21 16:58:49 +03:30
);
2026-09-03 14:20:55 +03:30
if ( personId ) {
const person = await this . prisma . person . findFirst ({
2026-09-21 19:24:39 +03:30
where : { id : personId , organizationId : orgId },
2026-09-03 14:20:55 +03:30
include : { company : { select : { name : true } } },
});
if ( person ) {
catalog . push ({
type : 'person' ,
id : person.id ,
label : ` ${ person . firstName } ${ person . lastName } ` . trim (),
});
lines . push (
`Person: ${ person . firstName } ${ person . lastName } | position: ${ person . position ?? '—' } | company: ${ person . company ? . name ?? '—' } | mood: ${ person . mood } | phone: ${ person . mobile || person . phone || '—' } | email: ${ person . email ?? '—' } ` ,
);
}
2026-09-21 19:24:39 +03:30
} else {
const seenPeople = new Set < string >();
for ( const p of [... peopleToday , ... recentPeople ]) {
if ( seenPeople . has ( p . id )) continue ;
seenPeople . add ( p . id );
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 ?? '—' } ` ,
);
}
2026-09-03 14:20:55 +03:30
}
for ( const item of memories ) {
catalog . push ({ type : 'memory' , id : item.id , label : clip ( item . content , 60 ) });
lines . push ( `Memory ${ item . id } : ${ clip ( item . content , 220 ) } ` );
}
for ( const note of notes ) {
const who = note . person
? ` ${ note . person . firstName } ${ note . person . lastName } `
: '' ;
catalog . push ({
type : 'note' ,
id : note.id ,
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 ) } ` ,
);
}
for ( const activity of activities ) {
const who = activity . person
? ` ${ activity . person . firstName } ${ activity . person . lastName } `
: '' ;
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 ) } ` : '' } ` ,
);
}
for ( const deal of deals ) {
const who = deal . person
? ` ${ deal . person . firstName } ${ deal . person . lastName } `
: '' ;
catalog . push ({ type : 'deal' , id : deal.id , label : deal.title });
const flags = [
deal . isStale ? 'stale' : null ,
deal . isRotten ? 'rotten' : null ,
]
. filter ( Boolean )
. join ( ',' );
lines . push (
`Deal ${ deal . id } ${ deal . title } status= ${ deal . status } stage= ${ deal . stage . name } amount= ${ deal . amount != null ? String ( deal . amount ) : '—' } ${ deal . currency }${ flags ? ` flags= ${ flags } ` : '' }${ who ? ` person= ${ who } ` : '' } ` ,
);
}
2026-09-21 19:24:39 +03:30
return { context : lines.join ( '\n' ). slice ( 0 , 14000 ), catalog };
2026-09-03 14:20:55 +03:30
}
2026-09-23 15:49:44 +03:30
async parseVoiceCommand ( actor : Actor , text : string ) : Promise < VoiceCommandDraft > {
const now = new Date ();
const today = new Intl . DateTimeFormat ( 'en-CA' , {
timeZone : 'Asia/Tehran' ,
year : 'numeric' ,
month : '2-digit' ,
day : '2-digit' ,
}). format ( now );
const raw = await this . ai . completeJson < {
intent? : string ;
summary? : string ;
person? : Partial < VoicePersonDraft > | null ;
activity? : Partial < VoiceActivityDraft > | null ;
} > (
`You convert one Persian voice command into a CRM action draft.
Return ONLY JSON:
{
"intent": "create_person" | "create_activity" | "none",
"summary": string,
"person": { "firstName": string, "lastName": string, "mobile": string, "phone": string, "email": string, "website": string, "referrer": string, "position": string, "notes": string } | null,
"activity": { "title": string, "type": "call"|"meeting"|"task"|"note", "description": string, "dueAt": string|null, "personName": string } | null
}
Rules:
- create_person only when the user asks to add/create a customer or contact.
- create_activity only when the user asks to add a follow-up, call, meeting, or task.
- Otherwise intent is none. Do not invent a record.
- Do not invent phone numbers, emails, or names that were not spoken.
- Mobile/phone: digits only, Iranian mobile as 09xxxxxxxxx.
- dueAt: ISO 8601 in Asia/Tehran if a time is mentioned, else null. Today is ${ today } .
- summary: one short Persian sentence describing what will be saved.
- Unused string fields are empty strings.` ,
text . trim (),
);
const intent =
raw . intent === 'create_person' || raw . intent === 'create_activity'
? raw . intent
: 'none' ;
if ( intent === 'create_person' ) {
const person = cleanPerson ( raw . person );
const missing : string [] = [];
if ( ! person . firstName ) missing . push ( 'نام' );
return {
intent ,
summary : ( raw . summary ?? '' ). trim () || `ساخت مشتری ${ person . firstName } ${ person . lastName } ` . trim (),
missing ,
canConfirm : missing.length === 0 ,
person ,
activity : null ,
};
}
if ( intent === 'create_activity' ) {
const activity = cleanActivity ( raw . activity );
if ( activity . personName ) {
activity . personId = await this . matchPersonId ( actor , activity . personName );
}
const missing : string [] = [];
if ( ! activity . title ) missing . push ( 'عنوان فعالیت' );
return {
intent ,
summary : ( raw . summary ?? '' ). trim () || `ثبت فعالیت « ${ activity . title } »` ,
missing ,
canConfirm : missing.length === 0 ,
person : null ,
activity ,
};
}
return {
intent : 'none' ,
summary :
( raw . summary ?? '' ). trim () ||
'در این مرحله فقط ساخت مشتری یا ثبت فعالیت از روی صوت انجام میشود.' ,
missing : [],
canConfirm : false ,
person : null ,
activity : null ,
};
}
private async matchPersonId ( actor : Actor , name : string ) : Promise < string | null > {
const q = name . trim ();
if ( q . length < 2 ) return null ;
const rows = await this . prisma . person . findMany ({
where : {
organizationId : actor.organizationId ,
... ownerWhere ( actor ),
OR : [
{ firstName : { contains : q , mode : 'insensitive' } },
{ lastName : { contains : q , mode : 'insensitive' } },
],
},
select : { id : true , firstName : true , lastName : true },
take : 5 ,
});
const exact = rows . filter (( row ) => ` ${ row . firstName } ${ row . lastName } ` . trim () === q );
if ( exact . length === 1 ) return exact [ 0 ] ! . id ;
if ( rows . length === 1 ) return rows [ 0 ] ! . id ;
return null ;
}
}
function cleanText ( value : unknown , max = 200 ) {
return String ( value ?? '' ). replace ( /\s+/g , ' ' ). trim (). slice ( 0 , max );
}
function digitsOnly ( value : unknown ) {
const fa = '۰۱۲۳۴۵۶۷۸۹' ;
const ar = '٠١٢٣٤٥٦٧٨٩' ;
const raw = String ( value ?? '' )
. replace ( /[۰ -۹]/g , ( ch ) => String ( fa . indexOf ( ch )))
. replace ( /[٠ -٩]/g , ( ch ) => String ( ar . indexOf ( ch )));
let digits = raw . replace ( /[^\d+]/g , '' );
if ( digits . startsWith ( '+98' )) digits = `0 ${ digits . slice ( 3 ) } ` ;
else if ( digits . startsWith ( '98' ) && digits . length === 12 ) digits = `0 ${ digits . slice ( 2 ) } ` ;
return digits . slice ( 0 , 20 );
}
function cleanPerson ( input : Partial < VoicePersonDraft > | null | undefined ) : VoicePersonDraft {
return {
firstName : cleanText ( input ? . firstName , 80 ),
lastName : cleanText ( input ? . lastName , 80 ),
mobile : digitsOnly ( input ? . mobile ),
phone : digitsOnly ( input ? . phone ),
email : cleanText ( input ? . email , 120 ),
website : cleanText ( input ? . website , 300 ),
referrer : cleanText ( input ? . referrer , 120 ),
position : cleanText ( input ? . position , 120 ),
notes : cleanText ( input ? . notes , 500 ),
};
}
const ACTIVITY_TYPES = new Set < ActivityType >([ 'call' , 'meeting' , 'task' , 'note' ]);
function cleanActivity ( input : Partial < VoiceActivityDraft > | null | undefined ) : VoiceActivityDraft {
const type = ACTIVITY_TYPES . has ( input ? . type as ActivityType )
? ( input ? . type as ActivityType )
: 'task' ;
const due = cleanText ( input ? . dueAt , 40 );
return {
title : cleanText ( input ? . title , 160 ),
type ,
description : cleanText ( input ? . description , 500 ),
dueAt : due && ! Number . isNaN ( Date . parse ( due )) ? new Date ( due ). toISOString () : null ,
personName : cleanText ( input ? . personName , 120 ),
personId : null ,
};
2026-09-03 14:20:55 +03:30
}