@@ -9,6 +9,37 @@ export interface ComposeStageRow {
analytics : string [ ] ;
}
interface ComposeFactualReplyOptions {
userMessage? : string ;
}
type PeriodProfileFocus =
| "full_profile"
| "coverage_years"
| "top_year_docs"
| "bottom_year_docs"
| "top_month_ops"
| "bottom_month_ops" ;
type DocumentSectionProfileFocus =
| "full_profile"
| "doc_types_only"
| "doc_types_rare_only"
| "sections_only"
| "sections_rare_only" ;
type CounterpartyProfileFocus =
| "full_profile"
| "total_only"
| "roles_only"
| "suppliers_only"
| "customers_only"
| "mixed_only" ;
type CounterpartyLifecycleFocus = "active_customers_period" | "active_customers_all_time" ;
interface YearAggPoint {
year : number ;
count : number ;
}
function uniqueStrings ( values : string [ ] ) : string [ ] {
return Array . from (
new Set (
@@ -29,6 +60,280 @@ function formatTopRows(rows: ComposeStageRow[], limit = 6): string[] {
} ) ;
}
function extractYearFromIso ( value : string | null ) : number | null {
const source = String ( value ? ? "" ) ;
const match = source . match ( /^(\d{4})-(\d{2})-(\d{2})/ ) ;
if ( ! match ) {
return null ;
}
const year = Number ( match [ 1 ] ) ;
return Number . isFinite ( year ) ? year : null ;
}
function extractYearMonthFromIso ( value : string | null ) : string | null {
const source = String ( value ? ? "" ) ;
const match = source . match ( /^(\d{4})-(\d{2})-(\d{2})/ ) ;
if ( ! match ) {
return null ;
}
return ` ${ match [ 1 ] } - ${ match [ 2 ] } ` ;
}
const ACCOUNT_SECTION_LABELS : Record < string , string > = {
"01" : "Основные средства" ,
"04" : "Нематериальные активы" ,
"08" : "Вложения во внеоборотные активы" ,
"10" : "Материалы" ,
"19" : "НДС по приобретенным ценностям" ,
"20" : "Основное производство" ,
"23" : "Вспомогательные производства" ,
"25" : "Общепроизводственные расходы" ,
"26" : "Общехозяйственные расходы" ,
"41" : "Товары" ,
"43" : "Готовая продукция" ,
"44" : "Расходы на продажу" ,
"50" : "Касса" ,
"51" : "Расчетные счета" ,
"52" : "Валютные счета" ,
"55" : "Специальные счета в банках" ,
"58" : "Финансовые вложения" ,
"60" : "Расчеты с поставщиками и подрядчиками" ,
"62" : "Расчеты с покупателями и заказчиками" ,
"66" : "Краткосрочные кредиты и займы" ,
"67" : "Долгосрочные кредиты и займы" ,
"68" : "Расчеты по налогам и сборам" ,
"69" : "Расчеты по социальному страхованию" ,
"70" : "Расчеты с персоналом по оплате труда" ,
"71" : "Расчеты с подотчетными лицами" ,
"73" : "Расчеты с персоналом по прочим операциям" ,
"75" : "Расчеты с учредителями" ,
"76" : "Расчеты с разными дебиторами и кредиторами" ,
"80" : "Уставный капитал" ,
"81" : "Собственные акции (доли)" ,
"84" : "Нераспределенная прибыль (непокрытый убыток)" ,
"90" : "Продажи" ,
"91" : "Прочие доходы и расходы"
} ;
function formatPercent ( value : number , total : number ) : string | null {
if ( ! Number . isFinite ( value ) || ! Number . isFinite ( total ) || total <= 0 ) {
return null ;
}
return ` ${ ( ( value / total ) * 100 ) . toFixed ( 1 ) } % ` ;
}
function extractAccountSectionCode ( value : string | null ) : string | null {
const source = String ( value ? ? "" ) . trim ( ) ;
if ( ! source ) {
return null ;
}
const match = source . match ( /(^|[^0-9])(\d{2})(?:[.,]\d{1,2})?/ ) ;
if ( ! match ) {
return null ;
}
return match [ 2 ] ;
}
function normalizeQuestionText ( value : string | null | undefined ) : string {
return String ( value ? ? "" )
. toLowerCase ( )
. replace ( /ё/g , "е " )
. replace ( /\s+/g , " " )
. trim ( ) ;
}
function detectPeriodProfileFocus ( userMessage : string | null | undefined ) : PeriodProfileFocus {
const text = normalizeQuestionText ( userMessage ) ;
if ( ! text ) {
return "full_profile" ;
}
const asksYear = / ( ? : \ b y e a r \ b | г о д ( ? : а | у | о м | е | ы ) ? ) / i u . t e s t ( t e x t ) ;
const asksMonth = / ( ? : \ b m o n t h \ b | м е с я ц ( ? : а | у | е м | е | ы ) ? ) / i u . t e s t ( t e x t ) ;
const asksDocs = / ( ? : \ b d o c u m e n t ( ? : s ) ? \ b | д о к ( ? : у м е н т ( ? : ы | о в | а м | а м и | а х | а ) ? | и | о в ) ? ) / i u . t e s t ( t e x t ) ;
const asksOps = / ( ? : \ b o p s ? \ b | \ b o p e r a t i o n ( ? : s ) ? \ b | о п е р а ц ) / i u . t e s t ( t e x t ) ;
const asksTop = / ( ? : с а м ( ? : ы й | а я | о е ) \ s + а к т и в | н а и б о л [ е ё ] е \ s + а к т и в | ч а щ е \ s + в с е г о | m o s t \ s + a c t i v e | t o p ) / i u . t e s t ( t e x t ) ;
const asksBottom = / ( ? : с а м ( ? : ы й | а я | о е ) \ s + п а с с и в | н а и м е н [ е ё ] е \ s + а к т и в | l e a s t \ s + a c t i v e | м и н и м ( ? : у м | а л ь н ) | н а и м е н ь ш ) / i u . t e s t (
text
) ;
if ( asksYear && asksDocs && asksBottom ) {
return "bottom_year_docs" ;
}
if ( asksYear && asksDocs && asksTop ) {
return "top_year_docs" ;
}
if ( asksMonth && asksOps && asksBottom ) {
return "bottom_month_ops" ;
}
if ( asksMonth && asksOps && asksTop ) {
return "top_month_ops" ;
}
if ( / ( ? : з а \ s + к а к и е \ s + г о д [ а - я ё ] * | г о д ы ? \ s + с \ s + д а н н ы м и | п о к р ы т ( ? : и е | и я ) \ s + п е р и о д | д и а п а з о н \ s + л е т | п р о ф и л [ ь я ] \ s + д а н н | y e a r \ s + c o v e r a g e | d a t a \ s + c o v e r a g e ) / i u . t e s t ( t e x t ) ) {
return "coverage_years" ;
}
return "full_profile" ;
}
function detectDocumentSectionProfileFocus ( userMessage : string | null | undefined ) : DocumentSectionProfileFocus {
const text = normalizeQuestionText ( userMessage ) ;
if ( ! text ) {
return "full_profile" ;
}
const asksDocTypes = / ( ? : т и п [ а ы ] ? \ s + д о к | т и п ы ? \ s + д о к у м е н т | d o c u m e n t \ s + t y p e s ? ) / i u . t e s t ( t e x t ) ;
const asksSections = / ( ? : р а з д е л [ ы а ] ? \ s + у ч [ е ё ] т а | a c c o u n t \ s + s e c t i o n ) / i u . t e s t ( t e x t ) ;
const asksRare = / ( ? : р е ж е | р е д к | н а и м е н [ е ё ] е | п о ч т и \ s + н е | l e a s t | r a r e | м и н и м ( ? : у м | а л ь н ) ) / i u . t e s t ( t e x t ) ;
const asksTop = / ( ? : ч а щ е \ s + в с е г о | н а и б о л [ е ё ] е | m o s t | t o p | м а к с и м ) / i u . t e s t ( t e x t ) ;
if ( asksDocTypes && ! asksSections ) {
if ( asksRare && ! asksTop ) {
return "doc_types_rare_only" ;
}
return "doc_types_only" ;
}
if ( asksSections && ! asksDocTypes ) {
if ( asksRare && ! asksTop ) {
return "sections_rare_only" ;
}
return "sections_only" ;
}
return "full_profile" ;
}
function detectCounterpartyProfileFocus ( userMessage : string | null | undefined ) : CounterpartyProfileFocus {
const text = normalizeQuestionText ( userMessage ) ;
if ( ! text ) {
return "full_profile" ;
}
const asksTotal =
/ ( ? : ( ? : с к о л ь к о | с к о к а | с к о к ) \ s + ( ? : в с е г о \ s + ) ? ( ? : у н и к а л ь н ( ? : ы х | ы е | о г о ) ? \ s + ) ? к о н т р а г е н т ( ? : о в | а ) ? ( ? : \ s + в \ s + б а з [ е ы ] ) ? | t o t a l \ s + c o u n t e r p a r t ( ? : y | i e s ) ) / i u . t e s t (
text
) ;
const hasSupplierToken = / ( ? : п о с т а в щ и к ( ? : о в | а ) ? | s u p p l i e r ( ? : s ) ? ) / i u . t e s t ( t e x t ) ;
const hasCustomerToken = / ( ? : з а к а з ч и к ( ? : о в | а ) ? | к л и е н т ( ? : о в | а ) ? | c u s t o m e r ( ? : s ) ? | c l i e n t ( ? : s ) ? ) / i u . t e s t ( t e x t ) ;
const hasMixedToken = / ( ? : с м е ш а н | п р о ч ( ? : и х | и е ) | m i x e d ) / i u . t e s t ( t e x t ) ;
const asksRoles =
/ ( ? : з а к а з ч и к ( ? : о в | а ) ? | п о с т а в щ и к ( ? : о в | а ) ? | с м е ш а н | п р о ч ( ? : и х | и е ) | т и п ы ? \ s + к о н т р а г е н т | р а з б е й | р а з д е л и | r o l e s ? | s p l i t ) / i u . t e s t (
text
) ;
if ( hasSupplierToken && ! hasCustomerToken && ! hasMixedToken && ! asksTotal ) {
return "suppliers_only" ;
}
if ( hasCustomerToken && ! hasSupplierToken && ! hasMixedToken && ! asksTotal ) {
return "customers_only" ;
}
if ( hasMixedToken && ! hasSupplierToken && ! hasCustomerToken && ! asksTotal ) {
return "mixed_only" ;
}
if ( asksTotal && ! asksRoles ) {
return "total_only" ;
}
if ( asksRoles && ! asksTotal ) {
return "roles_only" ;
}
return "full_profile" ;
}
function detectCounterpartyLifecycleFocus ( userMessage : string | null | undefined ) : CounterpartyLifecycleFocus {
const text = normalizeQuestionText ( userMessage ) ;
if ( ! text ) {
return "active_customers_period" ;
}
if ( / ( ? : з а \ s + в с [ е ё ] \ s + в р е м я | a l l \ s + t i m e | з а \ s + в с ю \ s + и с т о р и ( ? : ю | и ) ) / i u . t e s t ( t e x t ) ) {
return "active_customers_all_time" ;
}
return "active_customers_period" ;
}
function extractRequestedYearFromQuestion ( userMessage : string | null | undefined ) : number | null {
const text = normalizeQuestionText ( userMessage ) ;
if ( ! text ) {
return null ;
}
const fullYearMatch = text . match ( /\b(19|20)\d{2}\b/ ) ;
if ( fullYearMatch ) {
const parsed = Number ( fullYearMatch [ 0 ] ) ;
return Number . isFinite ( parsed ) ? parsed : null ;
}
const shortYearMatch = text . match ( / ( ? : ^ | [ ^ \ d ] ) ( \ d { 2 } ) \ s * ( ? : г ( ? : о д | о д а ) ? | г ) ( ? : [ ^ \ p { L } \ p { N } ] | $ ) / i u ) ;
if ( ! shortYearMatch ) {
return null ;
}
const shortYear = Number ( shortYearMatch [ 1 ] ) ;
if ( ! Number . isFinite ( shortYear ) || shortYear < 0 || shortYear > 99 ) {
return null ;
}
return 2000 + shortYear ;
}
function extractCounterpartyName ( row : ComposeStageRow ) : string | null {
for ( const token of row . analytics ) {
const normalized = String ( token ? ? "" ) . trim ( ) ;
if ( ! normalized ) {
continue ;
}
if ( /^\d{4}-\d{2}-\d{2}/ . test ( normalized ) ) {
continue ;
}
return normalized ;
}
return null ;
}
function deriveOperationalYearWindow (
yearDocs : YearAggPoint [ ] ,
yearOps : YearAggPoint [ ]
) : {
dataFrom : number | null ;
dataTo : number | null ;
operationalFrom : number | null ;
operationalTo : number | null ;
tailYears : number [ ] ;
} {
const docsSeries = [ . . . yearDocs ] . sort ( ( a , b ) = > a . year - b . year ) ;
const fallbackSeries = [ . . . yearOps ] . sort ( ( a , b ) = > a . year - b . year ) ;
const series = docsSeries . length > 0 ? docsSeries : fallbackSeries ;
if ( series . length === 0 ) {
return {
dataFrom : null ,
dataTo : null ,
operationalFrom : null ,
operationalTo : null ,
tailYears : [ ]
} ;
}
const dataFrom = series [ 0 ] ? . year ? ? null ;
const dataTo = series [ series . length - 1 ] ? . year ? ? null ;
const maxCount = Math . max ( . . . series . map ( ( item ) = > item . count ) ) ;
const significantThreshold = Math . max ( 20 , Math . ceil ( maxCount * 0.02 ) ) ;
const significantYears = series . filter ( ( item ) = > item . count >= significantThreshold ) . map ( ( item ) = > item . year ) ;
const operationalFrom = significantYears [ 0 ] ? ? dataFrom ;
const operationalTo = significantYears [ significantYears . length - 1 ] ? ? dataTo ;
const tailYears = series
. filter ( ( item ) = > operationalTo !== null && item . year > operationalTo )
. map ( ( item ) = > item . year ) ;
return {
dataFrom ,
dataTo ,
operationalFrom ,
operationalTo ,
tailYears
} ;
}
export function contractCandidatesFromRows ( rows : ComposeStageRow [ ] ) : string [ ] {
const candidates : string [ ] = [ ] ;
for ( const row of rows ) {
@@ -47,8 +352,492 @@ export function contractCandidatesFromRows(rows: ComposeStageRow[]): string[] {
export function composeFactualReply (
intent : AddressIntent ,
rows : ComposeStageRow [ ]
rows : ComposeStageRow [ ] ,
options : ComposeFactualReplyOptions = { }
) : { responseType : AddressResponseType ; text : string } {
if ( intent === "document_type_and_account_section_profile" ) {
const rowsByMarker = new Map < string , ComposeStageRow [ ] > ( ) ;
for ( const row of rows ) {
const marker = String ( row . registrator ? ? "" ) . trim ( ) . toUpperCase ( ) ;
if ( ! marker ) {
continue ;
}
if ( ! rowsByMarker . has ( marker ) ) {
rowsByMarker . set ( marker , [ ] ) ;
}
rowsByMarker . get ( marker ) ! . push ( row ) ;
}
const docTypeRanking = ( rowsByMarker . get ( "DOC_TYPE_DOCS" ) ? ? [ ] )
. map ( ( row ) = > ( {
docType : String ( row . account_dt ? ? "" ) . trim ( ) ,
count : row.amount ? ? 0
} ) )
. filter ( ( item ) = > item . docType . length > 0 )
. sort ( ( a , b ) = > b . count - a . count ) ;
const docTypeRankingLow = [ . . . docTypeRanking ]
. sort ( ( a , b ) = > a . count - b . count || a . docType . localeCompare ( b . docType ) )
. slice ( 0 , 10 ) ;
const sectionCounter = new Map < string , number > ( ) ;
for ( const marker of [ "SECTION_DT_OPS" , "SECTION_KT_OPS" ] ) {
for ( const row of rowsByMarker . get ( marker ) ? ? [ ] ) {
const sectionCode = extractAccountSectionCode ( row . account_dt ) ;
if ( ! sectionCode ) {
continue ;
}
const nextValue = ( sectionCounter . get ( sectionCode ) ? ? 0 ) + ( row . amount ? ? 0 ) ;
sectionCounter . set ( sectionCode , nextValue ) ;
}
}
const sectionRanking = Array . from ( sectionCounter . entries ( ) )
. map ( ( [ section , count ] ) = > ( { section , count } ) )
. sort ( ( a , b ) = > b . count - a . count || a . section . localeCompare ( b . section ) ) ;
const sectionRankingLow = [ . . . sectionRanking ]
. sort ( ( a , b ) = > a . count - b . count || a . section . localeCompare ( b . section ) )
. slice ( 0 , 10 ) ;
const docTypeTotal = docTypeRanking . reduce ( ( sum , item ) = > sum + item . count , 0 ) ;
const sectionTotal = sectionRanking . reduce ( ( sum , item ) = > sum + item . count , 0 ) ;
const focus = detectDocumentSectionProfileFocus ( options . userMessage ) ;
const includeDocTypes =
focus === "full_profile" || focus === "doc_types_only" || focus === "doc_types_rare_only" ;
const includeSections =
focus === "full_profile" || focus === "sections_only" || focus === "sections_rare_only" ;
const includeDocTypesLowOnly = focus === "doc_types_rare_only" ;
const includeSectionsLowOnly = focus === "sections_rare_only" ;
const lines : string [ ] = [
"Профиль типов документов и разделов учета собран (movement-based aggregate)." ,
` Строк агрегата: ${ rows . length } . `
] ;
if ( includeDocTypes ) {
if ( docTypeRanking . length > 0 ) {
if ( includeDocTypesLowOnly ) {
lines . push ( "Наименее используемые типы документов (по числу уникальных регистраторов):" ) ;
lines . push (
. . . docTypeRankingLow . map ( ( item , index ) = > {
const share = formatPercent ( item . count , docTypeTotal ) ;
return share
? ` ${ index + 1 } . ${ item . docType } : ${ item . count } ( ${ share } ) `
: ` ${ index + 1 } . ${ item . docType } : ${ item . count } ` ;
} )
) ;
} else {
lines . push ( "Топ типов документов (по числу уникальных регистраторов):" ) ;
lines . push (
. . . docTypeRanking . slice ( 0 , 10 ) . map ( ( item , index ) = > {
const share = formatPercent ( item . count , docTypeTotal ) ;
return share
? ` ${ index + 1 } . ${ item . docType } : ${ item . count } ( ${ share } ) `
: ` ${ index + 1 } . ${ item . docType } : ${ item . count } ` ;
} )
) ;
}
} else {
lines . push ( "По типам документов агрегатных строк не найдено." ) ;
}
}
if ( includeSections ) {
if ( sectionRanking . length > 0 ) {
if ( includeSectionsLowOnly ) {
lines . push ( "Наименее заполненные разделы учета (по операциям Дт+Кт):" ) ;
lines . push (
. . . sectionRankingLow . map ( ( item , index ) = > {
const label = ACCOUNT_SECTION_LABELS [ item . section ] ;
const sectionTitle = label ? ` ${ item . section } ( ${ label } ) ` : item . section ;
const share = formatPercent ( item . count , sectionTotal ) ;
return share
? ` ${ index + 1 } . ${ sectionTitle } : ${ item . count } ( ${ share } ) `
: ` ${ index + 1 } . ${ sectionTitle } : ${ item . count } ` ;
} )
) ;
} else {
lines . push ( "Наиболее заполненные разделы учета (по операциям Дт+Кт):" ) ;
lines . push (
. . . sectionRanking . slice ( 0 , 10 ) . map ( ( item , index ) = > {
const label = ACCOUNT_SECTION_LABELS [ item . section ] ;
const sectionTitle = label ? ` ${ item . section } ( ${ label } ) ` : item . section ;
const share = formatPercent ( item . count , sectionTotal ) ;
return share
? ` ${ index + 1 } . ${ sectionTitle } : ${ item . count } ( ${ share } ) `
: ` ${ index + 1 } . ${ sectionTitle } : ${ item . count } ` ;
} )
) ;
lines . push ( "Разделы с минимальной активностью (среди использованных):" ) ;
lines . push (
. . . sectionRankingLow . map ( ( item , index ) = > {
const label = ACCOUNT_SECTION_LABELS [ item . section ] ;
const sectionTitle = label ? ` ${ item . section } ( ${ label } ) ` : item . section ;
return ` ${ index + 1 } . ${ sectionTitle } : ${ item . count } ` ;
} )
) ;
}
} else {
lines . push ( "По разделам учета агрегатных строк не найдено." ) ;
}
}
return {
responseType : "FACTUAL_SUMMARY" ,
text : lines.join ( "\n" )
} ;
}
if ( intent === "period_coverage_profile" ) {
const rowsByMarker = new Map < string , ComposeStageRow [ ] > ( ) ;
for ( const row of rows ) {
const marker = String ( row . registrator ? ? "" ) . trim ( ) . toUpperCase ( ) ;
if ( ! marker ) {
continue ;
}
if ( ! rowsByMarker . has ( marker ) ) {
rowsByMarker . set ( marker , [ ] ) ;
}
rowsByMarker . get ( marker ) ! . push ( row ) ;
}
const minDate = rowsByMarker . get ( "MIN_DATE" ) ? . [ 0 ] ? . period ? ? null ;
const maxDate = rowsByMarker . get ( "MAX_DATE" ) ? . [ 0 ] ? . period ? ? null ;
const yearOps : YearAggPoint [ ] = ( rowsByMarker . get ( "YEAR_OPS" ) ? ? [ ] )
. map ( ( row ) = > ( {
year : extractYearFromIso ( row . period ) ,
count : row.amount ? ? 0
} ) )
. filter ( ( item ) : item is YearAggPoint = > item . year !== null )
. sort ( ( a , b ) = > b . count - a . count ) ;
const yearDocs : YearAggPoint [ ] = ( rowsByMarker . get ( "YEAR_DOCS" ) ? ? [ ] )
. map ( ( row ) = > ( {
year : extractYearFromIso ( row . period ) ,
count : row.amount ? ? 0
} ) )
. filter ( ( item ) : item is YearAggPoint = > item . year !== null )
. sort ( ( a , b ) = > b . count - a . count ) ;
const monthOps = ( rowsByMarker . get ( "MONTH_OPS" ) ? ? [ ] )
. map ( ( row ) = > ( {
month : extractYearMonthFromIso ( row . period ) ,
count : row.amount ? ? 0
} ) )
. filter ( ( item ) : item is { month : string ; count : number } = > item . month !== null )
. sort ( ( a , b ) = > b . count - a . count ) ;
const focus = detectPeriodProfileFocus ( options . userMessage ) ;
const includeCoverage = focus === "full_profile" || focus === "coverage_years" ;
const includeTopYear = focus === "full_profile" || focus === "top_year_docs" ;
const includeBottomYear = focus === "bottom_year_docs" ;
const includeTopMonth = focus === "full_profile" || focus === "top_month_ops" ;
const includeBottomMonth = focus === "bottom_month_ops" ;
const operationalWindow = deriveOperationalYearWindow ( yearDocs , yearOps ) ;
const yearsCoverage = ( yearOps . length > 0 ? yearOps : yearDocs ) . map ( ( item ) = > item . year ) . sort ( ( a , b ) = > a - b ) ;
const yearDocsWithinOperational =
operationalWindow . operationalFrom !== null && operationalWindow . operationalTo !== null
? yearDocs . filter (
( item ) = > item . year >= operationalWindow . operationalFrom ! && item . year <= operationalWindow . operationalTo !
)
: yearDocs ;
const yearDocsForRanking = yearDocsWithinOperational . length > 0 ? yearDocsWithinOperational : yearDocs ;
const yearDocsTop = [ . . . yearDocsForRanking ] . sort ( ( a , b ) = > b . count - a . count || a . year - b . year ) ;
const yearDocsBottom = [ . . . yearDocsForRanking ] . sort ( ( a , b ) = > a . count - b . count || a . year - b . year ) ;
const monthOpsWithinOperational =
operationalWindow . operationalFrom !== null && operationalWindow . operationalTo !== null
? monthOps . filter ( ( item ) = > {
const year = Number ( item . month . slice ( 0 , 4 ) ) ;
return (
Number . isFinite ( year ) &&
year >= operationalWindow . operationalFrom ! &&
year <= operationalWindow . operationalTo !
) ;
} )
: monthOps ;
const monthOpsForRanking = monthOpsWithinOperational . length > 0 ? monthOpsWithinOperational : monthOps ;
const monthOpsTop = [ . . . monthOpsForRanking ] . sort ( ( a , b ) = > b . count - a . count || a . month . localeCompare ( b . month ) ) ;
const monthOpsBottom = [ . . . monthOpsForRanking ] . sort ( ( a , b ) = > a . count - b . count || a . month . localeCompare ( b . month ) ) ;
const topYearByDocs = yearDocsTop [ 0 ] ? ? null ;
const bottomYearByDocs = yearDocsBottom [ 0 ] ? ? null ;
const topMonthByOps = monthOpsTop [ 0 ] ? ? null ;
const bottomMonthByOps = monthOpsBottom [ 0 ] ? ? null ;
const hasTailYears =
operationalWindow . tailYears . length > 0 &&
operationalWindow . operationalTo !== null &&
operationalWindow . dataTo !== null &&
operationalWindow . operationalTo < operationalWindow . dataTo ;
const lines : string [ ] = [
"Профиль периодов базы собран (movement-based aggregate)." ,
` Строк агрегата: ${ rows . length } . `
] ;
if ( includeCoverage ) {
if (
hasTailYears &&
operationalWindow . operationalFrom !== null &&
operationalWindow . operationalTo !== null
) {
lines . push (
` Операционный период с выраженной активностью: ${ operationalWindow . operationalFrom } .. ${ operationalWindow . operationalTo } . `
) ;
lines . push ( ` Низкоактивный хвост (единичные записи): ${ operationalWindow . tailYears . join ( ", " ) } . ` ) ;
lines . push ( ` Полный технический диапазон дат: ${ minDate ? ? "н/д" } .. ${ maxDate ? ? "н/д" } . ` ) ;
} else {
lines . push ( ` Покрытие по датам: ${ minDate ? ? "н/д" } .. ${ maxDate ? ? "н/д" } . ` ) ;
if ( yearsCoverage . length > 0 ) {
lines . push (
` Годы с данными: ${ yearsCoverage [ 0 ] } .. ${ yearsCoverage [ yearsCoverage . length - 1 ] } (уникальных: ${ yearsCoverage . length } ). `
) ;
}
}
}
if ( includeTopYear && topYearByDocs ) {
lines . push ( ` Самый активный год по документам: ${ topYearByDocs . year } ( ${ topYearByDocs . count } ). ` ) ;
lines . push (
. . . yearDocsTop
. slice ( 0 , 5 )
. map ( ( item , index ) = > ` ${ index + 1 } . ${ item . year } : ${ item . count } ` )
) ;
}
if ( includeBottomYear && bottomYearByDocs ) {
lines . push ( ` Самый пассивный год по документам: ${ bottomYearByDocs . year } ( ${ bottomYearByDocs . count } ). ` ) ;
lines . push (
. . . yearDocsBottom
. slice ( 0 , 5 )
. map ( ( item , index ) = > ` ${ index + 1 } . ${ item . year } : ${ item . count } ` )
) ;
}
if ( includeTopMonth && topMonthByOps ) {
lines . push ( ` Самый активный месяц по операциям: ${ topMonthByOps . month } ( ${ topMonthByOps . count } ). ` ) ;
lines . push (
. . . monthOpsTop
. slice ( 0 , 5 )
. map ( ( item , index ) = > ` ${ index + 1 } . ${ item . month } : ${ item . count } ` )
) ;
}
if ( includeBottomMonth && bottomMonthByOps ) {
lines . push ( ` Самый пассивный месяц по операциям: ${ bottomMonthByOps . month } ( ${ bottomMonthByOps . count } ). ` ) ;
lines . push (
. . . monthOpsBottom
. slice ( 0 , 5 )
. map ( ( item , index ) = > ` ${ index + 1 } . ${ item . month } : ${ item . count } ` )
) ;
}
return {
responseType : "FACTUAL_SUMMARY" ,
text : lines.join ( "\n" )
} ;
}
if ( intent === "counterparty_population_and_roles" ) {
const rowsByMarker = new Map < string , ComposeStageRow [ ] > ( ) ;
for ( const row of rows ) {
const marker = String ( row . registrator ? ? "" ) . trim ( ) . toUpperCase ( ) ;
if ( ! marker ) {
continue ;
}
if ( ! rowsByMarker . has ( marker ) ) {
rowsByMarker . set ( marker , [ ] ) ;
}
rowsByMarker . get ( marker ) ! . push ( row ) ;
}
const sumMarker = ( marker : string ) : number = >
( rowsByMarker . get ( marker ) ? ? [ ] ) . reduce ( ( sum , row ) = > sum + ( row . amount ? ? 0 ) , 0 ) ;
const totalCounterparties = sumMarker ( "CP_TOTAL" ) ;
const customerActive = sumMarker ( "CP_CUSTOMER_ACTIVE" ) ;
const supplierActive = sumMarker ( "CP_SUPPLIER_ACTIVE" ) ;
const mixedActive = sumMarker ( "CP_MIXED_ACTIVE" ) ;
const activeUnion = sumMarker ( "CP_ACTIVE_UNION" ) ;
const customerOnly = Math . max ( 0 , customerActive - mixedActive ) ;
const supplierOnly = Math . max ( 0 , supplierActive - mixedActive ) ;
const resolvedActive = customerOnly + supplierOnly + mixedActive ;
const activeCounterparties = Math . max ( activeUnion , resolvedActive ) ;
const otherCounterparties = totalCounterparties > 0 ? Math . max ( 0 , totalCounterparties - resolvedActive ) : null ;
const focus = detectCounterpartyProfileFocus ( options . userMessage ) ;
const includeTotal = focus === "full_profile" || focus === "total_only" ;
const includeRoles = focus === "full_profile" || focus === "roles_only" ;
const lines : string [ ] = [
"Профиль контрагентов собран (catalog + bank-doc activity aggregate)." ,
` Строк агрегата: ${ rows . length } . `
] ;
if ( includeTotal ) {
if ( totalCounterparties > 0 ) {
lines . push ( ` Всего уникальных контрагентов в базе: ${ totalCounterparties } . ` ) ;
} else if ( activeCounterparties > 0 ) {
lines . push (
` Total из справочника не получен, оценка по активности в документах: ${ activeCounterparties } контрагентов. `
) ;
} else {
lines . push ( "По количеству контрагентов агрегатных строк не найдено." ) ;
}
}
if ( includeRoles ) {
if ( resolvedActive > 0 || activeCounterparties > 0 ) {
lines . push ( "Роли контрагентов по активности:" ) ;
lines . push ( ` 1. Заказчики (только customer-роль): ${ customerOnly } . ` ) ;
lines . push ( ` 2. Поставщики (только supplier-роль): ${ supplierOnly } . ` ) ;
lines . push ( ` 3. Смешанные (и покупатель, и поставщик): ${ mixedActive } . ` ) ;
lines . push ( ` 4. Активные контрагенты (union ролей): ${ activeCounterparties } . ` ) ;
if ( otherCounterparties !== null ) {
lines . push ( ` 5. Прочие/неактивные в выбранном окне: ${ otherCounterparties } . ` ) ;
}
} else {
lines . push ( "По role-split контрагентов агрегатных строк не найдено." ) ;
}
}
if ( focus === "suppliers_only" ) {
lines . push ( ` Поставщиков (только supplier-роль): ${ supplierOnly } . ` ) ;
}
if ( focus === "customers_only" ) {
lines . push ( ` Заказчиков (только customer-роль): ${ customerOnly } . ` ) ;
}
if ( focus === "mixed_only" ) {
lines . push ( ` Смешанных контрагентов (и customer, и supplier): ${ mixedActive } . ` ) ;
}
return {
responseType : "FACTUAL_SUMMARY" ,
text : lines.join ( "\n" )
} ;
}
if ( intent === "counterparty_activity_lifecycle" ) {
const activityRows = rows . filter (
( row ) = > String ( row . registrator ? ? "" ) . trim ( ) . toUpperCase ( ) === "CP_CUSTOMER_ACTIVITY"
) ;
const byCounterparty = new Map < string , { name : string ; opsCount : number ; lastPeriod : string | null } > ( ) ;
for ( const row of activityRows ) {
const name = extractCounterpartyName ( row ) ;
if ( ! name ) {
continue ;
}
const opsCount = Math . max ( 0 , Math . trunc ( row . amount ? ? 0 ) ) ;
const current = byCounterparty . get ( name ) ;
if ( ! current ) {
byCounterparty . set ( name , { name , opsCount , lastPeriod : row.period } ) ;
continue ;
}
if ( opsCount > current . opsCount ) {
current . opsCount = opsCount ;
}
if ( ( row . period ? ? "" ) > ( current . lastPeriod ? ? "" ) ) {
current . lastPeriod = row . period ;
}
}
const counterparties = Array . from ( byCounterparty . values ( ) ) . sort ( ( left , right ) = > {
if ( right . opsCount !== left . opsCount ) {
return right . opsCount - left . opsCount ;
}
return ( right . lastPeriod ? ? "" ) . localeCompare ( left . lastPeriod ? ? "" ) ;
} ) ;
const focus = detectCounterpartyLifecycleFocus ( options . userMessage ) ;
const requestedYear = extractRequestedYearFromQuestion ( options . userMessage ) ;
const scopeLabel =
focus === "active_customers_all_time"
? "за все время"
: requestedYear
? ` в ${ requestedYear } году `
: "в выбранном периоде" ;
const lines : string [ ] = [
"Собран профиль активности заказчиков (bank-doc activity aggregate)." ,
` Строк агрегата: ${ rows . length } . `
] ;
if ( counterparties . length === 0 ) {
lines . push ( "Активных заказчиков по выбранному окну не найдено." ) ;
return {
responseType : "FACTUAL_SUMMARY" ,
text : lines.join ( "\n" )
} ;
}
lines . push ( ` Активные заказчики ${ scopeLabel } : ${ counterparties . length } . ` ) ;
const visible = counterparties . slice ( 0 , 120 ) ;
lines . push (
. . . visible . map ( ( item , index ) = > {
const suffix = item . lastPeriod ? ` | последняя активность: ${ item . lastPeriod } ` : "" ;
return ` ${ index + 1 } . ${ item . name } | операций: ${ item . opsCount } ${ suffix } ` ;
} )
) ;
if ( counterparties . length > visible . length ) {
lines . push ( ` Показаны первые ${ visible . length } из ${ counterparties . length } заказчиков. ` ) ;
}
return {
responseType : "FACTUAL_LIST" ,
text : lines.join ( "\n" )
} ;
}
if ( intent === "contract_usage_overview" ) {
const rowsByMarker = new Map < string , ComposeStageRow [ ] > ( ) ;
for ( const row of rows ) {
const marker = String ( row . registrator ? ? "" ) . trim ( ) . toUpperCase ( ) ;
if ( ! marker ) {
continue ;
}
if ( ! rowsByMarker . has ( marker ) ) {
rowsByMarker . set ( marker , [ ] ) ;
}
rowsByMarker . get ( marker ) ! . push ( row ) ;
}
const sumMarker = ( marker : string ) : number = >
( rowsByMarker . get ( marker ) ? ? [ ] ) . reduce ( ( sum , row ) = > sum + ( row . amount ? ? 0 ) , 0 ) ;
const totalContracts = sumMarker ( "CT_TOTAL" ) ;
const usedContracts = sumMarker ( "CT_USED" ) ;
const unusedContracts =
totalContracts > 0 ? Math . max ( 0 , totalContracts - Math . min ( usedContracts , totalContracts ) ) : null ;
const usedShare = totalContracts > 0 ? formatPercent ( Math . min ( usedContracts , totalContracts ) , totalContracts ) : null ;
const lines : string [ ] = [
"Профиль договорной базы собран (catalog + usage aggregate)." ,
` Строк агрегата: ${ rows . length } . `
] ;
if ( totalContracts > 0 ) {
lines . push ( ` Всего договоров в базе: ${ totalContracts } . ` ) ;
} else {
lines . push ( "Общее количество договоров не получено (пустой/недоступный срез справочника)." ) ;
}
lines . push ( ` Использованных договоров (есть factual связь с операциями): ${ usedContracts } . ` ) ;
if ( unusedContracts !== null ) {
lines . push ( ` Неиспользуемых договоров: ${ unusedContracts } . ` ) ;
}
if ( usedShare ) {
lines . push ( ` Доля используемых договоров: ${ usedShare } . ` ) ;
}
return {
responseType : "FACTUAL_SUMMARY" ,
text : lines.join ( "\n" )
} ;
}
if ( intent === "account_balance_snapshot" ) {
const movementSum = rows . reduce ( ( sum , row ) = > sum + ( row . amount ? ? 0 ) , 0 ) ;
const lines = [
@@ -109,6 +898,47 @@ export function composeFactualReply(
} ;
}
if ( intent === "list_contracts_by_counterparty" ) {
const contracts = uniqueStrings (
rows
. map ( ( row ) = > String ( row . registrator ? ? "" ) . trim ( ) )
. filter ( ( item ) = > item . length > 0 )
) ;
const counterparties = uniqueStrings (
rows
. flatMap ( ( row ) = > row . analytics )
. map ( ( item ) = > String ( item ? ? "" ) . trim ( ) )
. filter ( ( item ) = > item . length > 0 )
) ;
const lines : string [ ] = [
"Собран список договоров по контрагенту (catalog address lane)." ,
` Строк отобрано: ${ rows . length } . ` ,
` Уникальных договоров: ${ contracts . length } . `
] ;
if ( counterparties . length === 1 ) {
lines . push ( ` Контрагент: ${ counterparties [ 0 ] } . ` ) ;
} else if ( counterparties . length > 1 ) {
lines . push ( ` Контрагенты в выборке: ${ counterparties . length } . ` ) ;
}
if ( contracts . length > 0 ) {
const visible = contracts . slice ( 0 , 120 ) ;
lines . push ( . . . visible . map ( ( item , index ) = > ` ${ index + 1 } . ${ item } ` ) ) ;
if ( contracts . length > visible . length ) {
lines . push ( ` Показаны первые ${ visible . length } из ${ contracts . length } договоров. ` ) ;
}
} else {
lines . push ( "Договоры по указанному якорю в текущем live-срезе не найдены." ) ;
}
return {
responseType : "FACTUAL_LIST" ,
text : lines.join ( "\n" )
} ;
}
if ( intent === "list_documents_by_counterparty" ) {
const lines = [
"Собран список документов по контрагенту (live address lane)." ,