Semantic Gate: закрепить бизнес-аудит и executive summary
This commit is contained in:
@@ -14,6 +14,14 @@ function hasPriorAssistantTurn(items) {
|
||||
function buildDeterministicSmalltalkLeadReply() {
|
||||
return "\u041f\u0440\u0438\u0432\u0435\u0442! \u0412\u0441\u0451 \u043d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u043e.";
|
||||
}
|
||||
function hasConversationExecutiveSummarySignal(value) {
|
||||
const normalized = String(value ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/\u0451/gu, "\u0435")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return /(?:executive\s+summary|финальн\w*\s+собери|итогов\w*\s+(?:резюм|summary|вывод)|по\s+всему\s+диалогу|где\s+ответы\s+были\s+подтвержден|где\s+proxy|где\s+прокси|не\s+хватил\w*\s+доказательств|ручн\w*\s+(?:смотр|провер|контрол))/iu.test(normalized);
|
||||
}
|
||||
function asRecord(value) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
||||
}
|
||||
@@ -169,15 +177,25 @@ async function runAssistantLivingChatRuntime(input) {
|
||||
livingChatSource = "deterministic_inventory_history_capability_contract";
|
||||
}
|
||||
else if (contextualMemoryRecapFollowup) {
|
||||
const scopedOrganization = selectedOrganization ?? activeOrganization ?? null;
|
||||
chatText = (0, assistantMemoryRecapPolicy_1.buildAddressMemoryRecapReply)({
|
||||
organization: scopedOrganization,
|
||||
addressDebug: lastMemoryAddressDebug,
|
||||
sessionItems: input.sessionItems,
|
||||
toNonEmptyString: input.toNonEmptyString
|
||||
});
|
||||
const scopedOrganization = selectedOrganization ?? activeOrganization ?? continuityActiveOrganization ?? null;
|
||||
const executiveSummaryFollowup = hasConversationExecutiveSummarySignal(userMessage);
|
||||
chatText = executiveSummaryFollowup
|
||||
? (0, assistantMemoryRecapPolicy_1.buildConversationExecutiveSummaryReply)({
|
||||
organization: scopedOrganization,
|
||||
addressDebug: lastMemoryAddressDebug,
|
||||
sessionItems: input.sessionItems,
|
||||
toNonEmptyString: input.toNonEmptyString
|
||||
})
|
||||
: (0, assistantMemoryRecapPolicy_1.buildAddressMemoryRecapReply)({
|
||||
organization: scopedOrganization,
|
||||
addressDebug: lastMemoryAddressDebug,
|
||||
sessionItems: input.sessionItems,
|
||||
toNonEmptyString: input.toNonEmptyString
|
||||
});
|
||||
activeOrganization = scopedOrganization ?? activeOrganization;
|
||||
livingChatSource = "deterministic_memory_recap_contract";
|
||||
livingChatSource = executiveSummaryFollowup
|
||||
? "deterministic_conversation_executive_summary_contract"
|
||||
: "deterministic_memory_recap_contract";
|
||||
}
|
||||
else if (contextualAnswerInspectionFollowup) {
|
||||
chatText = (0, assistantMemoryRecapPolicy_1.buildSelectedObjectAnswerInspectionReply)({
|
||||
|
||||
@@ -119,6 +119,9 @@ function createAssistantLivingModePolicy(deps) {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function hasConversationExecutiveSummarySignal(sample) {
|
||||
return /(?:executive\s+summary|финальн\w*\s+собери|итогов\w*\s+(?:резюм|summary|вывод)|по\s+всему\s+диалогу|где\s+ответы\s+были\s+подтвержден|где\s+proxy|где\s+прокси|не\s+хватил\w*\s+доказательств|ручн\w*\s+(?:смотр|провер|контрол))/iu.test(sample);
|
||||
}
|
||||
function hasConversationMemoryRecallFollowupSignal(userMessage) {
|
||||
const rawText = compactWhitespace(String(userMessage ?? "").toLowerCase());
|
||||
const repairedText = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase());
|
||||
@@ -130,7 +133,8 @@ function createAssistantLivingModePolicy(deps) {
|
||||
}
|
||||
const hasMemoryCue = samples.some((sample) => /(?:помни(?:шь|те|м)?|remember|recall)/iu.test(sample));
|
||||
const hasDiscussionCue = samples.some((sample) => /(?:обсуждал[аи]?|говорил[аи]?|смотрел[аи]?|разбирал[аи]?|спрашивал[аи]?)/iu.test(sample));
|
||||
const hasExplicitRecapPrompt = samples.some((sample) => /(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|напомни\s+что\s+мы|what\s+we\s+already\s+(?:discussed|figured\s+out))/iu.test(sample));
|
||||
const hasExplicitRecapPrompt = samples.some((sample) => /(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|напомни\s+что\s+мы|what\s+we\s+already\s+(?:discussed|figured\s+out))/iu.test(sample) ||
|
||||
hasConversationExecutiveSummarySignal(sample));
|
||||
if (!(hasExplicitRecapPrompt || (hasMemoryCue && hasDiscussionCue))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildInventoryHistoryCapabilityFollowupReply = buildInventoryHistoryCapabilityFollowupReply;
|
||||
exports.buildAddressMemoryRecapReply = buildAddressMemoryRecapReply;
|
||||
exports.buildBroadBusinessEvaluationReply = buildBroadBusinessEvaluationReply;
|
||||
exports.buildConversationExecutiveSummaryReply = buildConversationExecutiveSummaryReply;
|
||||
exports.buildSelectedObjectAnswerInspectionReply = buildSelectedObjectAnswerInspectionReply;
|
||||
exports.resolveAssistantLivingChatMemoryContext = resolveAssistantLivingChatMemoryContext;
|
||||
exports.createAssistantMemoryRecapPolicy = createAssistantMemoryRecapPolicy;
|
||||
@@ -168,7 +169,7 @@ function hasSignalAcrossSamples(samples, detector) {
|
||||
return samples.some((sample) => detector(sample));
|
||||
}
|
||||
function hasExplicitRecapPromptSignal(samples) {
|
||||
return samples.some((sample) => /(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|что\s+уже\s+поняли|напомни\s+что\s+мы)/iu.test(sample));
|
||||
return samples.some((sample) => /(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|что\s+уже\s+поняли|напомни\s+что\s+мы|executive\s+summary|финальн\w*\s+собери|итогов\w*\s+(?:резюм|summary|вывод)|по\s+всему\s+диалогу|где\s+ответы\s+были\s+подтвержден|где\s+proxy|где\s+прокси|не\s+хватил\w*\s+доказательств|ручн\w*\s+(?:смотр|провер|контрол))/iu.test(sample));
|
||||
}
|
||||
function buildInventoryHistoryCapabilityFollowupReply(input) {
|
||||
const contextFacts = (0, assistantContinuityPolicy_1.resolveAddressDebugContextFacts)(input.addressDebug, input.toNonEmptyString);
|
||||
@@ -196,12 +197,45 @@ function normalizeRecapIdentity(value) {
|
||||
.replace(/[«»"'`]/g, "")
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
function isLowQualityRecapCounterparty(value) {
|
||||
const normalized = normalizeRecapIdentity(value);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const stopwordOnlyCounterparty = new Set([
|
||||
"без",
|
||||
"в",
|
||||
"во",
|
||||
"для",
|
||||
"до",
|
||||
"за",
|
||||
"из",
|
||||
"к",
|
||||
"ко",
|
||||
"на",
|
||||
"от",
|
||||
"по",
|
||||
"с",
|
||||
"со",
|
||||
"у"
|
||||
]);
|
||||
if (stopwordOnlyCounterparty.has(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (/^(?:и\s+)?(?:кто|что|где|какой|какие)\b/iu.test(normalized) ||
|
||||
/(?:главн|основн|крупн|поставщик|клиент|контрагент|покупател|документ|движени|операци)/iu.test(normalized) &&
|
||||
!/(?<!\p{L})(?:ооо|ип|ао|пао|зао|llc|inc|corp)(?!\p{L})/iu.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
return /(?:\s+в|\s+по|\s+для)$/iu.test(normalized);
|
||||
}
|
||||
function buildRecapFactLine(input) {
|
||||
const detectedIntent = String(input.debug?.detected_intent ?? "");
|
||||
const scopedDate = (0, assistantContinuityPolicy_1.resolveAddressDebugContextFacts)(input.debug).scopedDate;
|
||||
const counterparty = isLowQualityRecapCounterparty(input.counterparty) ? null : input.counterparty;
|
||||
const discoveryFact = buildDiscoveryRecapFactLine({
|
||||
debug: input.debug,
|
||||
counterparty: input.counterparty,
|
||||
counterparty,
|
||||
organization: input.organization,
|
||||
scopedDate
|
||||
});
|
||||
@@ -209,7 +243,7 @@ function buildRecapFactLine(input) {
|
||||
return discoveryFact;
|
||||
}
|
||||
const itemPart = input.item ? `по позиции «${input.item}»` : null;
|
||||
const counterpartyPart = input.counterparty ? `по контрагенту «${input.counterparty}»` : null;
|
||||
const counterpartyPart = counterparty ? `по контрагенту «${counterparty}»` : null;
|
||||
const organizationPart = input.organization ? `по компании «${input.organization}»` : null;
|
||||
const datePart = scopedDate ? ` на ${scopedDate}` : "";
|
||||
if (detectedIntent === "inventory_on_hand_as_of_date") {
|
||||
@@ -516,7 +550,7 @@ function buildBroadBusinessEvaluationReply(input) {
|
||||
: "- Прибыль, маржа и качество операционки пока не доказаны: нужны расходы, себестоимость и задолженность."
|
||||
];
|
||||
return [
|
||||
`Коротко: по уже подтвержденным срезам 1С${organizationPart} компания выглядит операционно живой; это предварительная оценка бизнеса, а для взрослого вывода еще нужны прибыль, маржа и долги.`,
|
||||
`Коротко: по уже подтвержденным срезам 1С${organizationPart} бизнес выглядит операционно живым; это предварительная оценка бизнеса, а для взрослого вывода еще нужны прибыль, маржа и долги.`,
|
||||
"Что уже видно:",
|
||||
...recapFacts.map((fact) => `- ${ensureSentence(fact)}`),
|
||||
...(businessEvidence.confirmedLines.length > 0
|
||||
@@ -533,6 +567,69 @@ function buildBroadBusinessEvaluationReply(input) {
|
||||
"Если хочешь, я быстро доберу основу для такой оценки: денежный поток, дебиторка/кредиторка, НДС или ключевые контрагенты."
|
||||
].join(" ");
|
||||
}
|
||||
function buildConversationExecutiveSummaryReply(input) {
|
||||
const contextFacts = (0, assistantContinuityPolicy_1.resolveAddressDebugContextFacts)(input.addressDebug, input.toNonEmptyString);
|
||||
const organization = input.organization ?? contextFacts.organization;
|
||||
const organizationPart = organization ? ` по компании «${organization}»` : "";
|
||||
const recapFacts = collectRecentRecapFacts({
|
||||
sessionItems: input.sessionItems,
|
||||
item: null,
|
||||
organization,
|
||||
toNonEmptyString: input.toNonEmptyString,
|
||||
limit: 8
|
||||
});
|
||||
const businessEvidence = collectBusinessEvaluationEvidence({
|
||||
sessionItems: input.sessionItems,
|
||||
organization,
|
||||
toNonEmptyString: input.toNonEmptyString
|
||||
});
|
||||
const confirmedLines = [];
|
||||
for (const fact of recapFacts) {
|
||||
pushBusinessLine(confirmedLines, ensureSentence(fact));
|
||||
}
|
||||
for (const fact of businessEvidence.confirmedLines) {
|
||||
pushBusinessLine(confirmedLines, ensureSentence(fact));
|
||||
}
|
||||
const proxyLines = [];
|
||||
for (const line of businessEvidence.interpretationLines) {
|
||||
pushBusinessLine(proxyLines, ensureSentence(line));
|
||||
}
|
||||
if (businessEvidence.hasNet) {
|
||||
pushBusinessLine(proxyLines, "Нетто по деньгам можно использовать как cash-flow proxy, но это не бухгалтерская прибыль и не маржа.");
|
||||
}
|
||||
if (businessEvidence.hasRanking) {
|
||||
pushBusinessLine(proxyLines, "Крупнейшие клиенты/контрагенты видны как операционный сигнал концентрации, но это не полноценный CRM-аудит.");
|
||||
}
|
||||
const missingLines = [
|
||||
"Чистая прибыль, финрезультат и маржа не доказаны без отдельной проверки себестоимости, расходов и закрытия периода.",
|
||||
"Просрочка, качество долга и due-date aging не доказаны без сроков оплаты и отдельного долгового контура.",
|
||||
"Ликвидность склада, резервы, списания и устаревание нельзя считать подтвержденными без специальных складских доказательств.",
|
||||
"Vendor-risk, качество закупок и юридическая надежность контрагентов остаются вне подтвержденного контура."
|
||||
];
|
||||
const manualLines = [
|
||||
"Сверить ОСВ/финрезультат и управленческие расходы за ключевые годы.",
|
||||
"Отдельно проверить старые открытые расчеты, крупные долги и документы закрытия.",
|
||||
"Посмотреть концентрацию клиентов и поставщиков не только по сумме, но и по доле в периоде.",
|
||||
"Сверить НДС, склад и договоры там, где ответ опирался на proxy, а не на прямой учетный факт."
|
||||
];
|
||||
const confirmedSection = confirmedLines.length > 0
|
||||
? confirmedLines.slice(0, 10).map((line) => `- ${line}`)
|
||||
: ["- Есть grounded-контекст диалога, но для строгого executive summary не хватает подтвержденных метрик в текущем окне."];
|
||||
const proxySection = proxyLines.length > 0
|
||||
? proxyLines.slice(0, 6).map((line) => `- ${line}`)
|
||||
: ["- Proxy-выводы пока слабые: можно говорить только о направлении проверки, не о зрелой оценке бизнеса."];
|
||||
return [
|
||||
`Executive summary${organizationPart}: по диалогу уже можно собрать рабочую карту подтвержденного, proxy и ручного контроля, но не стоит выдавать это за полный аудит компании.`,
|
||||
"Подтверждено по данным/ответам 1С:",
|
||||
...confirmedSection,
|
||||
"Proxy и осторожная аналитика:",
|
||||
...proxySection,
|
||||
"Где не хватило доказательств:",
|
||||
...missingLines.map((line) => `- ${line}`),
|
||||
"Что директору смотреть руками в первую очередь:",
|
||||
...manualLines.map((line) => `- ${line}`)
|
||||
].join("\n");
|
||||
}
|
||||
function buildSelectedObjectAnswerInspectionReply(input) {
|
||||
const contextFacts = (0, assistantContinuityPolicy_1.resolveAddressDebugContextFacts)(input.addressDebug, input.toNonEmptyString);
|
||||
const itemLabel = contextFacts.item ?? "эта позиция";
|
||||
|
||||
@@ -578,6 +578,13 @@ function createAssistantRoutePolicy(deps) {
|
||||
hasOrganizationFactFollowupSignal(repairedRawUserMessage, sessionItems ?? []) ||
|
||||
hasOrganizationFactFollowupSignal(effectiveAddressUserMessage, sessionItems ?? []) ||
|
||||
hasOrganizationFactFollowupSignal(repairedEffectiveAddressUserMessage, sessionItems ?? []);
|
||||
const broadBusinessMeaningBoundary = Boolean(assistantTurnMeaning?.unsupported_but_understood_family === "broad_business_evaluation" &&
|
||||
assistantTurnMeaning?.stale_replay_forbidden === true &&
|
||||
!turnMeaningIntentCandidate &&
|
||||
!dataScopeMetaQuery &&
|
||||
!dangerOrCoercionSignal &&
|
||||
!groundedValueFlowFollowupContextDetected &&
|
||||
!organizationClarificationContinuationDetected);
|
||||
const hardMetaMode = resolveHardMetaMode({
|
||||
dataScopeMetaQuery,
|
||||
capabilityMetaQuery,
|
||||
@@ -613,6 +620,41 @@ function createAssistantRoutePolicy(deps) {
|
||||
};
|
||||
}
|
||||
if (hardMetaMode === "capability") {
|
||||
if (broadBusinessMeaningBoundary) {
|
||||
return {
|
||||
runAddressLane: false,
|
||||
toolGateDecision: "skip_address_lane",
|
||||
toolGateReason: "unsupported_current_turn_meaning_boundary",
|
||||
livingMode: "chat",
|
||||
livingReason: "unsupported_current_turn_meaning_boundary",
|
||||
orchestrationContract: {
|
||||
schema_version: "assistant_orchestration_contract_v1",
|
||||
hard_meta_mode: null,
|
||||
provider_execution: providerExecution,
|
||||
assistant_turn_meaning: assistantTurnMeaning,
|
||||
address_mode: resolvedModeDetection.mode,
|
||||
address_mode_confidence: resolvedModeDetection.confidence,
|
||||
address_intent: resolvedIntentResolution.intent,
|
||||
address_intent_confidence: resolvedIntentResolution.confidence,
|
||||
strong_data_signal_detected: strongDataSignal,
|
||||
data_retrieval_signal_detected: dataRetrievalSignal,
|
||||
followup_context_detected: Boolean(followupContext),
|
||||
unsupported_current_turn_meaning_boundary: true,
|
||||
unsupported_current_turn_family: assistantTurnMeaning.unsupported_but_understood_family,
|
||||
unsupported_address_intent_fallback_to_deep: false,
|
||||
final_decision: {
|
||||
run_address_lane: false,
|
||||
tool_gate_decision: "skip_address_lane",
|
||||
tool_gate_reason: "unsupported_current_turn_meaning_boundary",
|
||||
living_mode: "chat",
|
||||
living_reason: "unsupported_current_turn_meaning_boundary"
|
||||
},
|
||||
reason_codes: [
|
||||
"business_overview_meaning_overrides_capability_meta_noise"
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
if (contextualHistoricalCapabilityFollowupDetected) {
|
||||
return {
|
||||
runAddressLane: false,
|
||||
@@ -699,7 +741,7 @@ function createAssistantRoutePolicy(deps) {
|
||||
}
|
||||
};
|
||||
}
|
||||
const unsupportedCurrentTurnMeaningBoundary = Boolean(assistantTurnMeaning?.unsupported_but_understood_family &&
|
||||
const unsupportedCurrentTurnMeaningBoundary = Boolean((broadBusinessMeaningBoundary || assistantTurnMeaning?.unsupported_but_understood_family) &&
|
||||
assistantTurnMeaning?.stale_replay_forbidden === true &&
|
||||
!turnMeaningIntentCandidate &&
|
||||
!aggregateBusinessAnalyticsSignal &&
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
buildAddressMemoryRecapReply as buildAddressMemoryRecapReplyFromPolicy,
|
||||
buildBroadBusinessEvaluationReply as buildBroadBusinessEvaluationReplyFromPolicy,
|
||||
buildConversationExecutiveSummaryReply as buildConversationExecutiveSummaryReplyFromPolicy,
|
||||
buildSelectedObjectAnswerInspectionReply as buildSelectedObjectAnswerInspectionReplyFromPolicy,
|
||||
buildInventoryHistoryCapabilityFollowupReply as buildInventoryHistoryCapabilityFollowupReplyFromPolicy,
|
||||
resolveAssistantLivingChatMemoryContext
|
||||
@@ -81,6 +82,17 @@ function buildDeterministicSmalltalkLeadReply(): string {
|
||||
return "\u041f\u0440\u0438\u0432\u0435\u0442! \u0412\u0441\u0451 \u043d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u043e.";
|
||||
}
|
||||
|
||||
function hasConversationExecutiveSummarySignal(value: unknown): boolean {
|
||||
const normalized = String(value ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/\u0451/gu, "\u0435")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return /(?:executive\s+summary|финальн\w*\s+собери|итогов\w*\s+(?:резюм|summary|вывод)|по\s+всему\s+диалогу|где\s+ответы\s+были\s+подтвержден|где\s+proxy|где\s+прокси|не\s+хватил\w*\s+доказательств|ручн\w*\s+(?:смотр|провер|контрол))/iu.test(
|
||||
normalized
|
||||
);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
@@ -246,15 +258,25 @@ export async function runAssistantLivingChatRuntime(
|
||||
activeOrganization = scopedOrganization ?? activeOrganization;
|
||||
livingChatSource = "deterministic_inventory_history_capability_contract";
|
||||
} else if (contextualMemoryRecapFollowup) {
|
||||
const scopedOrganization = selectedOrganization ?? activeOrganization ?? null;
|
||||
chatText = buildAddressMemoryRecapReplyFromPolicy({
|
||||
organization: scopedOrganization,
|
||||
addressDebug: lastMemoryAddressDebug,
|
||||
sessionItems: input.sessionItems,
|
||||
toNonEmptyString: input.toNonEmptyString
|
||||
});
|
||||
const scopedOrganization = selectedOrganization ?? activeOrganization ?? continuityActiveOrganization ?? null;
|
||||
const executiveSummaryFollowup = hasConversationExecutiveSummarySignal(userMessage);
|
||||
chatText = executiveSummaryFollowup
|
||||
? buildConversationExecutiveSummaryReplyFromPolicy({
|
||||
organization: scopedOrganization,
|
||||
addressDebug: lastMemoryAddressDebug,
|
||||
sessionItems: input.sessionItems,
|
||||
toNonEmptyString: input.toNonEmptyString
|
||||
})
|
||||
: buildAddressMemoryRecapReplyFromPolicy({
|
||||
organization: scopedOrganization,
|
||||
addressDebug: lastMemoryAddressDebug,
|
||||
sessionItems: input.sessionItems,
|
||||
toNonEmptyString: input.toNonEmptyString
|
||||
});
|
||||
activeOrganization = scopedOrganization ?? activeOrganization;
|
||||
livingChatSource = "deterministic_memory_recap_contract";
|
||||
livingChatSource = executiveSummaryFollowup
|
||||
? "deterministic_conversation_executive_summary_contract"
|
||||
: "deterministic_memory_recap_contract";
|
||||
} else if (contextualAnswerInspectionFollowup) {
|
||||
chatText = buildSelectedObjectAnswerInspectionReplyFromPolicy({
|
||||
addressDebug: lastAnswerInspectionAddressDebug,
|
||||
|
||||
@@ -185,6 +185,10 @@ export function createAssistantLivingModePolicy(deps: AssistantLivingModePolicyD
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasConversationExecutiveSummarySignal(sample) {
|
||||
return /(?:executive\s+summary|финальн\w*\s+собери|итогов\w*\s+(?:резюм|summary|вывод)|по\s+всему\s+диалогу|где\s+ответы\s+были\s+подтвержден|где\s+proxy|где\s+прокси|не\s+хватил\w*\s+доказательств|ручн\w*\s+(?:смотр|провер|контрол))/iu.test(sample);
|
||||
}
|
||||
|
||||
function hasConversationMemoryRecallFollowupSignal(userMessage) {
|
||||
const rawText = compactWhitespace(String(userMessage ?? "").toLowerCase());
|
||||
const repairedText = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase());
|
||||
@@ -196,7 +200,8 @@ export function createAssistantLivingModePolicy(deps: AssistantLivingModePolicyD
|
||||
}
|
||||
const hasMemoryCue = samples.some((sample) => /(?:помни(?:шь|те|м)?|remember|recall)/iu.test(sample));
|
||||
const hasDiscussionCue = samples.some((sample) => /(?:обсуждал[аи]?|говорил[аи]?|смотрел[аи]?|разбирал[аи]?|спрашивал[аи]?)/iu.test(sample));
|
||||
const hasExplicitRecapPrompt = samples.some((sample) => /(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|напомни\s+что\s+мы|what\s+we\s+already\s+(?:discussed|figured\s+out))/iu.test(sample));
|
||||
const hasExplicitRecapPrompt = samples.some((sample) => /(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|напомни\s+что\s+мы|what\s+we\s+already\s+(?:discussed|figured\s+out))/iu.test(sample) ||
|
||||
hasConversationExecutiveSummarySignal(sample));
|
||||
if (!(hasExplicitRecapPrompt || (hasMemoryCue && hasDiscussionCue))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ function hasSignalAcrossSamples(
|
||||
|
||||
function hasExplicitRecapPromptSignal(samples: string[]): boolean {
|
||||
return samples.some((sample) =>
|
||||
/(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|что\s+уже\s+поняли|напомни\s+что\s+мы)/iu.test(
|
||||
/(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|что\s+уже\s+поняли|напомни\s+что\s+мы|executive\s+summary|финальн\w*\s+собери|итогов\w*\s+(?:резюм|summary|вывод)|по\s+всему\s+диалогу|где\s+ответы\s+были\s+подтвержден|где\s+proxy|где\s+прокси|не\s+хватил\w*\s+доказательств|ручн\w*\s+(?:смотр|провер|контрол))/iu.test(
|
||||
sample
|
||||
)
|
||||
);
|
||||
@@ -268,6 +268,41 @@ function normalizeRecapIdentity(value: unknown): string {
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function isLowQualityRecapCounterparty(value: string | null): boolean {
|
||||
const normalized = normalizeRecapIdentity(value);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const stopwordOnlyCounterparty = new Set([
|
||||
"без",
|
||||
"в",
|
||||
"во",
|
||||
"для",
|
||||
"до",
|
||||
"за",
|
||||
"из",
|
||||
"к",
|
||||
"ко",
|
||||
"на",
|
||||
"от",
|
||||
"по",
|
||||
"с",
|
||||
"со",
|
||||
"у"
|
||||
]);
|
||||
if (stopwordOnlyCounterparty.has(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
/^(?:и\s+)?(?:кто|что|где|какой|какие)\b/iu.test(normalized) ||
|
||||
/(?:главн|основн|крупн|поставщик|клиент|контрагент|покупател|документ|движени|операци)/iu.test(normalized) &&
|
||||
!/(?<!\p{L})(?:ооо|ип|ао|пао|зао|llc|inc|corp)(?!\p{L})/iu.test(normalized)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return /(?:\s+в|\s+по|\s+для)$/iu.test(normalized);
|
||||
}
|
||||
|
||||
function buildRecapFactLine(input: {
|
||||
debug: Record<string, unknown> | null;
|
||||
item: string | null;
|
||||
@@ -276,9 +311,10 @@ function buildRecapFactLine(input: {
|
||||
}): string | null {
|
||||
const detectedIntent = String(input.debug?.detected_intent ?? "");
|
||||
const scopedDate = resolveAddressDebugContextFacts(input.debug).scopedDate;
|
||||
const counterparty = isLowQualityRecapCounterparty(input.counterparty) ? null : input.counterparty;
|
||||
const discoveryFact = buildDiscoveryRecapFactLine({
|
||||
debug: input.debug,
|
||||
counterparty: input.counterparty,
|
||||
counterparty,
|
||||
organization: input.organization,
|
||||
scopedDate
|
||||
});
|
||||
@@ -286,7 +322,7 @@ function buildRecapFactLine(input: {
|
||||
return discoveryFact;
|
||||
}
|
||||
const itemPart = input.item ? `по позиции «${input.item}»` : null;
|
||||
const counterpartyPart = input.counterparty ? `по контрагенту «${input.counterparty}»` : null;
|
||||
const counterpartyPart = counterparty ? `по контрагенту «${counterparty}»` : null;
|
||||
const organizationPart = input.organization ? `по компании «${input.organization}»` : null;
|
||||
const datePart = scopedDate ? ` на ${scopedDate}` : "";
|
||||
if (detectedIntent === "inventory_on_hand_as_of_date") {
|
||||
@@ -673,7 +709,7 @@ export function buildBroadBusinessEvaluationReply(input: {
|
||||
: "- Прибыль, маржа и качество операционки пока не доказаны: нужны расходы, себестоимость и задолженность."
|
||||
];
|
||||
return [
|
||||
`Коротко: по уже подтвержденным срезам 1С${organizationPart} компания выглядит операционно живой; это предварительная оценка бизнеса, а для взрослого вывода еще нужны прибыль, маржа и долги.`,
|
||||
`Коротко: по уже подтвержденным срезам 1С${organizationPart} бизнес выглядит операционно живым; это предварительная оценка бизнеса, а для взрослого вывода еще нужны прибыль, маржа и долги.`,
|
||||
"Что уже видно:",
|
||||
...recapFacts.map((fact) => `- ${ensureSentence(fact)}`),
|
||||
...(businessEvidence.confirmedLines.length > 0
|
||||
@@ -692,6 +728,84 @@ export function buildBroadBusinessEvaluationReply(input: {
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export function buildConversationExecutiveSummaryReply(input: {
|
||||
organization: string | null;
|
||||
addressDebug: Record<string, unknown> | null;
|
||||
sessionItems?: unknown[];
|
||||
toNonEmptyString: (value: unknown) => string | null;
|
||||
}): string {
|
||||
const contextFacts = resolveAddressDebugContextFacts(input.addressDebug, input.toNonEmptyString);
|
||||
const organization = input.organization ?? contextFacts.organization;
|
||||
const organizationPart = organization ? ` по компании «${organization}»` : "";
|
||||
const recapFacts = collectRecentRecapFacts({
|
||||
sessionItems: input.sessionItems,
|
||||
item: null,
|
||||
organization,
|
||||
toNonEmptyString: input.toNonEmptyString,
|
||||
limit: 8
|
||||
});
|
||||
const businessEvidence = collectBusinessEvaluationEvidence({
|
||||
sessionItems: input.sessionItems,
|
||||
organization,
|
||||
toNonEmptyString: input.toNonEmptyString
|
||||
});
|
||||
const confirmedLines: string[] = [];
|
||||
for (const fact of recapFacts) {
|
||||
pushBusinessLine(confirmedLines, ensureSentence(fact));
|
||||
}
|
||||
for (const fact of businessEvidence.confirmedLines) {
|
||||
pushBusinessLine(confirmedLines, ensureSentence(fact));
|
||||
}
|
||||
const proxyLines: string[] = [];
|
||||
for (const line of businessEvidence.interpretationLines) {
|
||||
pushBusinessLine(proxyLines, ensureSentence(line));
|
||||
}
|
||||
if (businessEvidence.hasNet) {
|
||||
pushBusinessLine(
|
||||
proxyLines,
|
||||
"Нетто по деньгам можно использовать как cash-flow proxy, но это не бухгалтерская прибыль и не маржа."
|
||||
);
|
||||
}
|
||||
if (businessEvidence.hasRanking) {
|
||||
pushBusinessLine(
|
||||
proxyLines,
|
||||
"Крупнейшие клиенты/контрагенты видны как операционный сигнал концентрации, но это не полноценный CRM-аудит."
|
||||
);
|
||||
}
|
||||
const missingLines = [
|
||||
"Чистая прибыль, финрезультат и маржа не доказаны без отдельной проверки себестоимости, расходов и закрытия периода.",
|
||||
"Просрочка, качество долга и due-date aging не доказаны без сроков оплаты и отдельного долгового контура.",
|
||||
"Ликвидность склада, резервы, списания и устаревание нельзя считать подтвержденными без специальных складских доказательств.",
|
||||
"Vendor-risk, качество закупок и юридическая надежность контрагентов остаются вне подтвержденного контура."
|
||||
];
|
||||
const manualLines = [
|
||||
"Сверить ОСВ/финрезультат и управленческие расходы за ключевые годы.",
|
||||
"Отдельно проверить старые открытые расчеты, крупные долги и документы закрытия.",
|
||||
"Посмотреть концентрацию клиентов и поставщиков не только по сумме, но и по доле в периоде.",
|
||||
"Сверить НДС, склад и договоры там, где ответ опирался на proxy, а не на прямой учетный факт."
|
||||
];
|
||||
const confirmedSection =
|
||||
confirmedLines.length > 0
|
||||
? confirmedLines.slice(0, 10).map((line) => `- ${line}`)
|
||||
: ["- Есть grounded-контекст диалога, но для строгого executive summary не хватает подтвержденных метрик в текущем окне."];
|
||||
const proxySection =
|
||||
proxyLines.length > 0
|
||||
? proxyLines.slice(0, 6).map((line) => `- ${line}`)
|
||||
: ["- Proxy-выводы пока слабые: можно говорить только о направлении проверки, не о зрелой оценке бизнеса."];
|
||||
|
||||
return [
|
||||
`Executive summary${organizationPart}: по диалогу уже можно собрать рабочую карту подтвержденного, proxy и ручного контроля, но не стоит выдавать это за полный аудит компании.`,
|
||||
"Подтверждено по данным/ответам 1С:",
|
||||
...confirmedSection,
|
||||
"Proxy и осторожная аналитика:",
|
||||
...proxySection,
|
||||
"Где не хватило доказательств:",
|
||||
...missingLines.map((line) => `- ${line}`),
|
||||
"Что директору смотреть руками в первую очередь:",
|
||||
...manualLines.map((line) => `- ${line}`)
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildSelectedObjectAnswerInspectionReply(input: {
|
||||
addressDebug: Record<string, unknown> | null;
|
||||
toNonEmptyString: (value: unknown) => string | null;
|
||||
|
||||
@@ -662,6 +662,14 @@ export function createAssistantRoutePolicy(deps) {
|
||||
hasOrganizationFactFollowupSignal(repairedRawUserMessage, sessionItems ?? []) ||
|
||||
hasOrganizationFactFollowupSignal(effectiveAddressUserMessage, sessionItems ?? []) ||
|
||||
hasOrganizationFactFollowupSignal(repairedEffectiveAddressUserMessage, sessionItems ?? []);
|
||||
const broadBusinessMeaningBoundary = Boolean(
|
||||
assistantTurnMeaning?.unsupported_but_understood_family === "broad_business_evaluation" &&
|
||||
assistantTurnMeaning?.stale_replay_forbidden === true &&
|
||||
!turnMeaningIntentCandidate &&
|
||||
!dataScopeMetaQuery &&
|
||||
!dangerOrCoercionSignal &&
|
||||
!groundedValueFlowFollowupContextDetected &&
|
||||
!organizationClarificationContinuationDetected);
|
||||
const hardMetaMode = resolveHardMetaMode({
|
||||
dataScopeMetaQuery,
|
||||
capabilityMetaQuery,
|
||||
@@ -697,6 +705,41 @@ export function createAssistantRoutePolicy(deps) {
|
||||
};
|
||||
}
|
||||
if (hardMetaMode === "capability") {
|
||||
if (broadBusinessMeaningBoundary) {
|
||||
return {
|
||||
runAddressLane: false,
|
||||
toolGateDecision: "skip_address_lane",
|
||||
toolGateReason: "unsupported_current_turn_meaning_boundary",
|
||||
livingMode: "chat",
|
||||
livingReason: "unsupported_current_turn_meaning_boundary",
|
||||
orchestrationContract: {
|
||||
schema_version: "assistant_orchestration_contract_v1",
|
||||
hard_meta_mode: null,
|
||||
provider_execution: providerExecution,
|
||||
assistant_turn_meaning: assistantTurnMeaning,
|
||||
address_mode: resolvedModeDetection.mode,
|
||||
address_mode_confidence: resolvedModeDetection.confidence,
|
||||
address_intent: resolvedIntentResolution.intent,
|
||||
address_intent_confidence: resolvedIntentResolution.confidence,
|
||||
strong_data_signal_detected: strongDataSignal,
|
||||
data_retrieval_signal_detected: dataRetrievalSignal,
|
||||
followup_context_detected: Boolean(followupContext),
|
||||
unsupported_current_turn_meaning_boundary: true,
|
||||
unsupported_current_turn_family: assistantTurnMeaning.unsupported_but_understood_family,
|
||||
unsupported_address_intent_fallback_to_deep: false,
|
||||
final_decision: {
|
||||
run_address_lane: false,
|
||||
tool_gate_decision: "skip_address_lane",
|
||||
tool_gate_reason: "unsupported_current_turn_meaning_boundary",
|
||||
living_mode: "chat",
|
||||
living_reason: "unsupported_current_turn_meaning_boundary"
|
||||
},
|
||||
reason_codes: [
|
||||
"business_overview_meaning_overrides_capability_meta_noise"
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
if (contextualHistoricalCapabilityFollowupDetected) {
|
||||
return {
|
||||
runAddressLane: false,
|
||||
@@ -784,7 +827,7 @@ export function createAssistantRoutePolicy(deps) {
|
||||
};
|
||||
}
|
||||
const unsupportedCurrentTurnMeaningBoundary = Boolean(
|
||||
assistantTurnMeaning?.unsupported_but_understood_family &&
|
||||
(broadBusinessMeaningBoundary || assistantTurnMeaning?.unsupported_but_understood_family) &&
|
||||
assistantTurnMeaning?.stale_replay_forbidden === true &&
|
||||
!turnMeaningIntentCandidate &&
|
||||
!aggregateBusinessAnalyticsSignal &&
|
||||
|
||||
@@ -426,6 +426,20 @@ describe("assistant living chat runtime adapter", () => {
|
||||
userMessage: "а ты помнишь мы зеркало обсуждали?",
|
||||
modeDecision: { mode: "chat", reason: "memory_recap_followup_detected" },
|
||||
sessionItems: [
|
||||
{
|
||||
role: "assistant",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
},
|
||||
detected_intent: "list_documents_by_counterparty",
|
||||
extracted_filters: {
|
||||
organization: "ООО Альтернатива Плюс",
|
||||
counterparty: "для"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
debug: {
|
||||
@@ -504,6 +518,81 @@ describe("assistant living chat runtime adapter", () => {
|
||||
expect(executeLlmChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("builds executive summary from memory instead of running generic address lookup", async () => {
|
||||
const executeLlmChat = vi.fn(async () => "raw-llm");
|
||||
const input = buildRuntimeInput({
|
||||
userMessage:
|
||||
"Финально собери executive summary по всему диалогу: где подтверждено, где proxy и что смотреть руками.",
|
||||
modeDecision: { mode: "chat", reason: "memory_recap_followup_detected" },
|
||||
sessionItems: [
|
||||
{
|
||||
role: "assistant",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
answer_grounding_check: {
|
||||
status: "grounded"
|
||||
},
|
||||
detected_intent: "list_documents_by_counterparty",
|
||||
extracted_filters: {
|
||||
organization: "ООО Альтернатива Плюс",
|
||||
counterparty: "для"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
debug: {
|
||||
execution_lane: "living_chat",
|
||||
mcp_discovery_response_applied: true,
|
||||
assistant_mcp_discovery_entry_point_v1: {
|
||||
schema_version: "assistant_mcp_discovery_runtime_entry_point_v1",
|
||||
entry_status: "bridge_executed",
|
||||
turn_input: {
|
||||
turn_meaning_ref: {
|
||||
explicit_organization_scope: "ООО Альтернатива Плюс",
|
||||
explicit_date_scope: "2020"
|
||||
}
|
||||
},
|
||||
bridge: {
|
||||
bridge_status: "answer_draft_ready",
|
||||
business_fact_answer_allowed: true,
|
||||
answer_draft: {
|
||||
answer_mode: "confirmed_with_bounded_inference"
|
||||
},
|
||||
pilot: {
|
||||
pilot_scope: "counterparty_bidirectional_value_flow_query_movements_v1",
|
||||
derived_bidirectional_value_flow: {
|
||||
period_scope: "2020",
|
||||
net_amount_human_ru: "3 865 501,50 руб.",
|
||||
incoming_customer_revenue: {
|
||||
total_amount_human_ru: "47 628 853,03 руб."
|
||||
},
|
||||
outgoing_supplier_payout: {
|
||||
total_amount_human_ru: "43 763 351,53 руб."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
executeLlmChat
|
||||
});
|
||||
|
||||
const output = await runAssistantLivingChatRuntime(input);
|
||||
|
||||
expect(output.handled).toBe(true);
|
||||
expect(output.chatText).toContain("Executive summary");
|
||||
expect(output.chatText).toContain("Подтверждено");
|
||||
expect(output.chatText).toContain("Proxy");
|
||||
expect(output.chatText).toContain("3 865 501,50");
|
||||
expect(output.chatText).toContain("директору смотреть руками");
|
||||
expect(output.chatText).not.toContain("«для»");
|
||||
expect(output.debug?.living_chat_response_source).toBe("deterministic_conversation_executive_summary_contract");
|
||||
expect(executeLlmChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses continuity-backed active organization for organization-fact boundary even when session scope is empty", async () => {
|
||||
const executeLlmChat = vi.fn(async () => "raw-llm");
|
||||
const input = buildRuntimeInput({
|
||||
|
||||
@@ -50,6 +50,16 @@ describe("assistantLivingModePolicy", () => {
|
||||
expect(policy.hasConversationMemoryRecallFollowupSignal("а что мы уже выяснили по этой позиции?")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects final executive summary wording as memory signal", () => {
|
||||
const policy = buildPolicy();
|
||||
|
||||
expect(
|
||||
policy.hasConversationMemoryRecallFollowupSignal(
|
||||
"Финально собери executive summary по всему диалогу: где подтверждено, где proxy и что смотреть руками"
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects answer inspection wording for previous answer correction", () => {
|
||||
const policy = buildPolicy();
|
||||
|
||||
|
||||
@@ -104,6 +104,42 @@ describe("assistantMemoryRecapPolicy", () => {
|
||||
expect(signals.contextualMemoryRecapFollowupDetected).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps final executive summary in memory lane even with strong data wording", () => {
|
||||
const executivePolicy = createAssistantMemoryRecapPolicy({
|
||||
hasHistoricalCapabilityFollowupSignal: () => false,
|
||||
hasConversationMemoryRecallFollowupSignal: (text: unknown) => /executive summary/i.test(String(text ?? "")),
|
||||
isGroundedInventoryContextDebug: () => false
|
||||
});
|
||||
|
||||
const signals = executivePolicy.resolveRouteMemorySignals({
|
||||
rawUserMessage:
|
||||
"Финально собери executive summary по всему диалогу: где подтверждено, где proxy и где не хватило доказательств.",
|
||||
repairedRawUserMessage: "",
|
||||
effectiveAddressUserMessage: "",
|
||||
repairedEffectiveAddressUserMessage: "",
|
||||
dataScopeMetaQuery: false,
|
||||
capabilityMetaQuery: false,
|
||||
dataRetrievalSignal: false,
|
||||
strongDataSignal: true,
|
||||
aggregateBusinessAnalyticsSignal: false,
|
||||
sessionItems: [
|
||||
{
|
||||
role: "assistant",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
answer_grounding_check: { status: "grounded" },
|
||||
detected_intent: "receivables_confirmed_as_of_date",
|
||||
extracted_filters: {
|
||||
organization: "ООО Альтернатива Плюс"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(signals.contextualMemoryRecapFollowupDetected).toBe(true);
|
||||
});
|
||||
|
||||
it("does not trigger recap from ungrounded address history", () => {
|
||||
const signals = policy.resolveRouteMemorySignals({
|
||||
rawUserMessage: "а ты помнишь что мы обсуждали?",
|
||||
|
||||
@@ -683,6 +683,45 @@ describe("assistantRoutePolicy", () => {
|
||||
expect(decision.orchestrationContract?.unsupported_current_turn_family).toBe("broad_business_evaluation");
|
||||
});
|
||||
|
||||
it("lets broad business-audit meaning override noisy capability wording", () => {
|
||||
const policy = buildPolicy({
|
||||
resolveMetaSignalSet: () => ({
|
||||
dataScopeMetaQuery: false,
|
||||
capabilityMetaQuery: true,
|
||||
metaAnswerFollowupSignal: false,
|
||||
answerInspectionFollowupSignal: false
|
||||
}),
|
||||
resolveAssistantTurnMeaning: () => ({
|
||||
schema_version: "assistant_turn_meaning_v1",
|
||||
asked_domain_family: "business_summary",
|
||||
asked_action_family: "broad_evaluation",
|
||||
explicit_intent_candidate: null,
|
||||
unsupported_but_understood_family: "broad_business_evaluation",
|
||||
stale_replay_forbidden: true,
|
||||
reason_codes: ["broad_business_evaluation_current_turn_signal"]
|
||||
})
|
||||
});
|
||||
|
||||
const decision = policy.resolveAssistantOrchestrationDecision({
|
||||
rawUserMessage:
|
||||
"Собери это как нормальный бизнес-аудит: что уже можно сказать уверенно, что proxy и что директору проверить руками.",
|
||||
effectiveAddressUserMessage:
|
||||
"Собери это как нормальный бизнес-аудит: что уже можно сказать уверенно, что proxy и что директору проверить руками.",
|
||||
followupContext: null,
|
||||
llmPreDecomposeMeta: null,
|
||||
useMock: false
|
||||
});
|
||||
|
||||
expect(decision.runAddressLane).toBe(false);
|
||||
expect(decision.toolGateReason).toBe("unsupported_current_turn_meaning_boundary");
|
||||
expect(decision.livingReason).toBe("unsupported_current_turn_meaning_boundary");
|
||||
expect(decision.orchestrationContract?.unsupported_current_turn_family).toBe("broad_business_evaluation");
|
||||
expect(decision.orchestrationContract?.hard_meta_mode).toBeNull();
|
||||
expect((decision.orchestrationContract as Record<string, unknown>)?.reason_codes).toContain(
|
||||
"business_overview_meaning_overrides_capability_meta_noise"
|
||||
);
|
||||
});
|
||||
|
||||
it("recovers an address route from current-turn meaning when L0 resolver is noisy", () => {
|
||||
const policy = buildPolicy({
|
||||
resolveAddressToolGateDecision: undefined,
|
||||
|
||||
Reference in New Issue
Block a user