Post-F: закрепить open-scope и bidirectional value-flow integrity

This commit is contained in:
dctouch 2026-04-24 15:21:03 +03:00
parent 9ed5435866
commit bba4717dbe
6 changed files with 165 additions and 0 deletions

View File

@ -1554,6 +1554,17 @@ function repairLikelyUtf8Mojibake(text) {
function unicodeBridgeResolution(intent, confidence, reason) {
return { intent, confidence, reasons: [reason] };
}
function hasBidirectionalValueFlowComparisonSignal(text) {
const normalized = String(text ?? "").trim().toLowerCase();
if (!normalized) {
return false;
}
const hasIncomingCue = /(?:\u0432\u0445\u043e\u0434\u044f\u0449|\u043f\u043e\u0441\u0442\u0443\u043f|\u043f\u043e\u043b\u0443\u0447|inflow|incoming)/iu.test(normalized);
const hasOutgoingCue = /(?:\u0438\u0441\u0445\u043e\u0434\u044f\u0449|\u0441\u043f\u0438\u0441\u0430\u043d|\u0437\u0430\u043f\u043b\u0430\u0442|\u043f\u043b\u0430\u0442\u0438\u043b|\u043e\u043f\u043b\u0430\u0442|outflow|outgoing|payout)/iu.test(normalized);
const hasComparisonCue = /(?:\u0431\u043e\u043b\u044c\u0448|\u043c\u0435\u043d\u044c\u0448|\u0441\u0440\u0430\u0432|\u0438\u043b\u0438|\u043d\u0435\u0442\u0442\u043e|\u0441\u0430\u043b\u044c\u0434\u043e|vs|versus)/iu.test(normalized);
const hasValueFlowCue = /(?:\u0434\u0435\u043d\u044c\u0433|\u0434\u0435\u043d\u0435\u0433|\u0434\u0435\u043d\u0435\u0436|\u043f\u043e\u0442\u043e\u043a|\u043e\u0431\u043e\u0440\u043e\u0442|money|cash|flow)/iu.test(normalized);
return hasIncomingCue && hasOutgoingCue && hasComparisonCue && hasValueFlowCue;
}
function resolveUnicodeAddressIntentBridge(text) {
const normalized = String(text ?? "").trim().toLowerCase();
if (!normalized) {
@ -1592,6 +1603,9 @@ function resolveUnicodeAddressIntentBridge(text) {
]).has(byAnchorToken);
const hasMoneyCue = /(?:деньг|денег|выручк|доход|оборот|заработ|прин[её]с|чек|ликвидн|revenue|turnover|money)/iu.test(normalized);
const hasRankingCue = /(?:топ|ранк|сам(?:ый|ая|ое|ые)|больше\s+всего|наибольш|крупн|жирн|max|top|rank)/iu.test(normalized);
if (hasBidirectionalValueFlowComparisonSignal(normalized)) {
return unicodeBridgeResolution("unknown", "high", "unicode_bidirectional_value_flow_deferred_to_discovery");
}
if (/(?:за\s+какие\s+годы|диапазон\s+лет|покрыт(?:ие|ый)\s+период|какой\s+год.*актив|какой\s+месяц.*актив|год.*пассив|месяц.*пассив|минимальн.*док|минимальн.*операц|месяц[\s-]*пик|profile\s+period|top\s*year|top\s*month)/iu.test(normalized)) {
return unicodeBridgeResolution("period_coverage_profile", "high", "unicode_period_coverage_bridge_signal_detected");
}

View File

@ -9,6 +9,7 @@ const resolveStage_1 = require("./address_runtime/resolveStage");
const composeStage_1 = require("./address_runtime/composeStage");
const addressCapabilityPolicy_1 = require("./addressCapabilityPolicy");
const addressRouteExpectations_1 = require("./addressRouteExpectations");
const addressTextRepair_1 = require("./addressTextRepair");
const assistantOrganizationMatcher_1 = require("./assistantOrganizationMatcher");
const addressCoverageEvidencePolicy_1 = require("./addressCoverageEvidencePolicy");
const addressTruthGatePolicy_1 = require("./addressTruthGatePolicy");
@ -1617,6 +1618,21 @@ function isOrganizationScopedInventoryIntent(intent) {
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date");
}
function isOrganizationScopedValueFlowIntent(intent) {
return intent === "customer_revenue_and_payments" || intent === "supplier_payouts_profile";
}
function hasExplicitSingleOrganizationValueFlowScopeRequest(userMessage) {
const raw = String(userMessage ?? "").toLowerCase();
const repaired = (0, addressTextRepair_1.repairAddressMojibakeText)(raw).toLowerCase();
const normalized = `${raw} ${repaired}`;
const hasOrganizationCue = /(?:организац|компани|фирм|контур|organization|company)/iu.test(normalized);
if (!hasOrganizationCue) {
return false;
}
const hasSingleOrganizationCue = /(?:по\s+одн(?:ой|у)\s+(?:организац|компани|фирм)|одн(?:ой|у)\s+(?:организац|компани|фирм)|one\s+(?:organization|company))/iu.test(normalized);
const hasClarificationCue = /(?:если[^.?!]*(?:нужн|надо|потреб)[^.?!]*(?:организац|компани|фирм)|(?:уточн|спрос)[^.?!]*(?:организац|компани|фирм|ее|её))/iu.test(normalized);
return hasSingleOrganizationCue || hasClarificationCue;
}
function shouldDeferInventoryOrganizationClarification(intent, filters, semanticFrame) {
if (!isOrganizationScopedInventoryIntent(intent)) {
return false;
@ -2804,6 +2820,27 @@ class AddressQueryService {
});
const knownOrganizations = (0, assistantOrganizationMatcher_1.mergeKnownOrganizations)(options.knownOrganizations ?? []);
const activeOrganization = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(options.activeOrganization ?? null);
if (isOrganizationScopedValueFlowIntent(intent.intent) &&
hasExplicitSingleOrganizationValueFlowScopeRequest(userMessage) &&
!resolvedOrganizationFromMessage) {
const clarificationFilters = { ...filters.extracted_filters };
delete clarificationFilters.organization;
return buildOrganizationClarificationExecutionResult({
mode,
shape,
intent,
filters: clarificationFilters,
organizations: knownOrganizations,
reasons: [...baseReasons, "organization_clarification_required_from_explicit_value_flow_scope"],
semanticFrame,
capabilityAudit: buildCapabilityAudit(intent.intent),
shadowRouteAudit: buildShadowRouteAudit({
intent: intent.intent,
requestedResultMode: (0, addressCoverageEvidencePolicy_1.resolveAddressRequestedResultMode)(intent.intent, filters.extracted_filters, semanticFrame) ?? undefined,
filters: filters.extracted_filters
})
});
}
if (isOrganizationScopedInventoryIntent(intent.intent) &&
!toNonEmptyFilterValue(filters.extracted_filters.organization) &&
!activeOrganization &&

View File

@ -1954,6 +1954,31 @@ function unicodeBridgeResolution(
return { intent, confidence, reasons: [reason] };
}
function hasBidirectionalValueFlowComparisonSignal(text: string): boolean {
const normalized = String(text ?? "").trim().toLowerCase();
if (!normalized) {
return false;
}
const hasIncomingCue = /(?:\u0432\u0445\u043e\u0434\u044f\u0449|\u043f\u043e\u0441\u0442\u0443\u043f|\u043f\u043e\u043b\u0443\u0447|inflow|incoming)/iu.test(
normalized
);
const hasOutgoingCue =
/(?:\u0438\u0441\u0445\u043e\u0434\u044f\u0449|\u0441\u043f\u0438\u0441\u0430\u043d|\u0437\u0430\u043f\u043b\u0430\u0442|\u043f\u043b\u0430\u0442\u0438\u043b|\u043e\u043f\u043b\u0430\u0442|outflow|outgoing|payout)/iu.test(
normalized
);
const hasComparisonCue =
/(?:\u0431\u043e\u043b\u044c\u0448|\u043c\u0435\u043d\u044c\u0448|\u0441\u0440\u0430\u0432|\u0438\u043b\u0438|\u043d\u0435\u0442\u0442\u043e|\u0441\u0430\u043b\u044c\u0434\u043e|vs|versus)/iu.test(
normalized
);
const hasValueFlowCue =
/(?:\u0434\u0435\u043d\u044c\u0433|\u0434\u0435\u043d\u0435\u0433|\u0434\u0435\u043d\u0435\u0436|\u043f\u043e\u0442\u043e\u043a|\u043e\u0431\u043e\u0440\u043e\u0442|money|cash|flow)/iu.test(
normalized
);
return hasIncomingCue && hasOutgoingCue && hasComparisonCue && hasValueFlowCue;
}
function resolveUnicodeAddressIntentBridge(text: string): AddressIntentResolution | null {
const normalized = String(text ?? "").trim().toLowerCase();
if (!normalized) {
@ -2006,6 +2031,14 @@ function resolveUnicodeAddressIntentBridge(text: string): AddressIntentResolutio
normalized
);
if (hasBidirectionalValueFlowComparisonSignal(normalized)) {
return unicodeBridgeResolution(
"unknown",
"high",
"unicode_bidirectional_value_flow_deferred_to_discovery"
);
}
if (
/(?:за\s+какие\s+годы|диапазон\s+лет|покрыт(?:ие|ый)\s+период|какой\s+год.*актив|какой\s+месяц.*актив|год.*пассив|месяц.*пассив|минимальн.*док|минимальн.*операц|месяц[\s-]*пик|profile\s+period|top\s*year|top\s*month)/iu.test(
normalized

View File

@ -56,6 +56,7 @@ import {
resolveShadowRouteIntent
} from "./addressCapabilityPolicy";
import { evaluateAddressRouteExpectation, type AddressRouteExpectationAudit } from "./addressRouteExpectations";
import { repairAddressMojibakeText } from "./addressTextRepair";
import {
mergeKnownOrganizations,
normalizeOrganizationScopeSearchText,
@ -1994,6 +1995,31 @@ function isOrganizationScopedInventoryIntent(intent: AddressIntent): boolean {
);
}
function isOrganizationScopedValueFlowIntent(intent: AddressIntent): boolean {
return intent === "customer_revenue_and_payments" || intent === "supplier_payouts_profile";
}
function hasExplicitSingleOrganizationValueFlowScopeRequest(userMessage: string): boolean {
const raw = String(userMessage ?? "").toLowerCase();
const repaired = repairAddressMojibakeText(raw).toLowerCase();
const normalized = `${raw} ${repaired}`;
const hasOrganizationCue = /(?:организац|компани|фирм|контур|organization|company)/iu.test(normalized);
if (!hasOrganizationCue) {
return false;
}
const hasSingleOrganizationCue =
/(?:по\s+одн(?:ой|у)\s+(?:организац|компани|фирм)|одн(?:ой|у)\s+(?:организац|компани|фирм)|one\s+(?:organization|company))/iu.test(
normalized
);
const hasClarificationCue =
/(?:если[^.?!]*(?:нужн|надо|потреб)[^.?!]*(?:организац|компани|фирм)|(?:уточн|спрос)[^.?!]*(?:организац|компани|фирм|ее|её))/iu.test(
normalized
);
return hasSingleOrganizationCue || hasClarificationCue;
}
function shouldDeferInventoryOrganizationClarification(
intent: AddressIntent,
filters: AddressFilterSet,
@ -3480,6 +3506,29 @@ export class AddressQueryService {
});
const knownOrganizations = mergeKnownOrganizations(options.knownOrganizations ?? []);
const activeOrganization = normalizeOrganizationScopeValue(options.activeOrganization ?? null);
if (
isOrganizationScopedValueFlowIntent(intent.intent) &&
hasExplicitSingleOrganizationValueFlowScopeRequest(userMessage) &&
!resolvedOrganizationFromMessage
) {
const clarificationFilters: AddressFilterSet = { ...filters.extracted_filters };
delete clarificationFilters.organization;
return buildOrganizationClarificationExecutionResult({
mode,
shape,
intent,
filters: clarificationFilters,
organizations: knownOrganizations,
reasons: [...baseReasons, "organization_clarification_required_from_explicit_value_flow_scope"],
semanticFrame,
capabilityAudit: buildCapabilityAudit(intent.intent),
shadowRouteAudit: buildShadowRouteAudit({
intent: intent.intent,
requestedResultMode: resolveAddressRequestedResultMode(intent.intent, filters.extracted_filters, semanticFrame) ?? undefined,
filters: filters.extracted_filters
})
});
}
if (
isOrganizationScopedInventoryIntent(intent.intent) &&
!toNonEmptyFilterValue(filters.extracted_filters.organization) &&

View File

@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { resolveAddressIntent } from "../src/services/addressIntentResolver";
describe("address intent resolver bidirectional value-flow arbitration", () => {
it("keeps incoming-vs-outgoing comparison out of one-sided payout routes", () => {
const result = resolveAddressIntent(
"\u0447\u0442\u043e \u043f\u043e \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441 \u0431\u043e\u043b\u044c\u0448\u0435 \u0432 2020 \u0433\u043e\u0434\u0443: \u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0435 \u0438\u043b\u0438 \u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0435 \u0434\u0435\u043d\u044c\u0433\u0438?"
);
expect(result.intent).toBe("unknown");
expect(result.reasons).toContain("unicode_bidirectional_value_flow_deferred_to_discovery");
});
});

View File

@ -4734,6 +4734,25 @@ describe("address decompose stage follow-up carryover", () => {
expect(result?.baseReasons).not.toContain("open_items_from_followup_context");
});
it("asks for organization when an open value-flow total explicitly requests one-organization scope", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle(
"\u0421 \u0416\u0443\u043a\u043e\u0432\u043a\u043e\u0439 \u0437\u0430\u043a\u043e\u043d\u0447\u0438\u043b\u0438. \u0422\u0435\u043f\u0435\u0440\u044c \u043d\u0443\u0436\u043d\u0430 \u0434\u0440\u0443\u0433\u0430\u044f \u0437\u0430\u0434\u0430\u0447\u0430: \u0431\u044b\u0441\u0442\u0440\u044b\u0439 \u0434\u0435\u043d\u0435\u0436\u043d\u044b\u0439 \u0441\u0440\u0435\u0437 \u043f\u043e \u043e\u0434\u043d\u043e\u0439 \u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u0438. \u0415\u0441\u043b\u0438 \u0434\u043b\u044f \u043e\u0442\u0432\u0435\u0442\u0430 \u043d\u0443\u0436\u043d\u0430 \u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044f, \u043f\u0440\u043e\u0441\u0442\u043e \u0443\u0442\u043e\u0447\u043d\u0438 \u0435\u0435. \u0421\u043a\u043e\u043b\u044c\u043a\u043e \u0432\u043e\u043e\u0431\u0449\u0435 \u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0445 \u0434\u0435\u043d\u0435\u0433 \u0431\u044b\u043b\u043e \u0437\u0430 2020 \u0433\u043e\u0434?",
{
knownOrganizations: [
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441",
"\u041e\u041e\u041e \u0420\u043e\u043c\u0430\u0448\u043a\u0430"
]
}
);
expect(result?.reply_type).toBe("partial_coverage");
expect(result?.debug.missing_required_filters).toContain("organization");
expect(result?.debug.mcp_call_status).toBe("skipped");
expect(result?.debug.reasons).toContain("organization_clarification_required_from_explicit_value_flow_scope");
expect(result?.reply_text).toMatch(/организац/iu);
});
it("keeps balance family in follow-up when user gives compact account token", () => {
const result = runAddressDecomposeStage("вернись на 2020-12-31 по 60", {
previous_intent: "documents_forming_balance",