АРЧ - Ассистент: отделить meta-followup по прошлому ответу от повторного запуска address lane
This commit is contained in:
@@ -3,19 +3,45 @@
|
||||
AddressIntent,
|
||||
AddressIntentResolution,
|
||||
AddressModeDetection,
|
||||
AddressQueryShapeDetection
|
||||
AddressQueryShapeDetection,
|
||||
AddressSemanticFrame
|
||||
} from "../../types/addressQuery";
|
||||
import { detectAddressQuestionMode } from "../addressQueryClassifier";
|
||||
import { classifyAddressQueryShape } from "../addressQueryShapeClassifier";
|
||||
import { resolveAddressIntent } from "../addressIntentResolver";
|
||||
import { extractAddressFilters } from "../addressFilterExtractor";
|
||||
import { applyAddressLlmSemanticHintsToExtraction } from "./semanticHintOverlay";
|
||||
import type { AddressLlmSemanticHints } from "../../types/addressQuery";
|
||||
|
||||
export interface AddressFollowupContext {
|
||||
previous_intent?: AddressIntent;
|
||||
previous_filters?: AddressFilterSet;
|
||||
previous_anchor_type?: "account" | "counterparty" | "contract" | "document_ref" | "item" | "warehouse" | "unknown" | null;
|
||||
previous_anchor_type?:
|
||||
| "account"
|
||||
| "counterparty"
|
||||
| "contract"
|
||||
| "document_ref"
|
||||
| "item"
|
||||
| "organization"
|
||||
| "warehouse"
|
||||
| "unknown"
|
||||
| null;
|
||||
previous_anchor_value?: string | null;
|
||||
resolved_counterparty_from_display?: boolean;
|
||||
root_intent?: AddressIntent;
|
||||
root_filters?: AddressFilterSet;
|
||||
root_anchor_type?:
|
||||
| "account"
|
||||
| "counterparty"
|
||||
| "contract"
|
||||
| "document_ref"
|
||||
| "item"
|
||||
| "organization"
|
||||
| "warehouse"
|
||||
| "unknown"
|
||||
| null;
|
||||
root_anchor_value?: string | null;
|
||||
current_frame_kind?: "generic" | "inventory_root" | "inventory_drilldown";
|
||||
}
|
||||
|
||||
export interface AddressDecomposeStageResult {
|
||||
@@ -26,6 +52,7 @@ export interface AddressDecomposeStageResult {
|
||||
extracted_filters: AddressFilterSet;
|
||||
missing_required_filters: string[];
|
||||
warnings: string[];
|
||||
semantic_frame?: AddressSemanticFrame;
|
||||
};
|
||||
baseReasons: string[];
|
||||
}
|
||||
@@ -318,6 +345,159 @@ function isInventoryIntent(intent: AddressIntent | undefined): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isInventoryRootFrameIntent(intent: AddressIntent | undefined): boolean {
|
||||
return intent === "inventory_on_hand_as_of_date";
|
||||
}
|
||||
|
||||
function isInventoryDrilldownFrameIntent(intent: AddressIntent | undefined): boolean {
|
||||
return (
|
||||
intent === "inventory_purchase_provenance_for_item" ||
|
||||
intent === "inventory_purchase_documents_for_item" ||
|
||||
intent === "inventory_sale_trace_for_item" ||
|
||||
intent === "inventory_purchase_to_sale_chain" ||
|
||||
intent === "inventory_aging_by_purchase_date"
|
||||
);
|
||||
}
|
||||
|
||||
function buildInventoryRootFollowupContext(
|
||||
followupContext: AddressFollowupContext | null
|
||||
): AddressFollowupContext | null {
|
||||
if (!followupContext || !followupContext.root_intent || !followupContext.root_filters) {
|
||||
return followupContext;
|
||||
}
|
||||
return {
|
||||
...followupContext,
|
||||
previous_intent: followupContext.root_intent,
|
||||
previous_filters: { ...followupContext.root_filters },
|
||||
previous_anchor_type: followupContext.root_anchor_type ?? followupContext.previous_anchor_type,
|
||||
previous_anchor_value: followupContext.root_anchor_value ?? followupContext.previous_anchor_value,
|
||||
current_frame_kind: "inventory_root"
|
||||
};
|
||||
}
|
||||
|
||||
function getTokenCount(text: string): number {
|
||||
return String(text ?? "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean).length;
|
||||
}
|
||||
|
||||
function resolveMonthNumberFromText(text: string): number | null {
|
||||
const normalized = String(text ?? "").toLowerCase();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (/январ|january|jan/iu.test(normalized)) return 1;
|
||||
if (/феврал|february|feb/iu.test(normalized)) return 2;
|
||||
if (/март|march|mar/iu.test(normalized)) return 3;
|
||||
if (/апрел|april|apr/iu.test(normalized)) return 4;
|
||||
if (/(?:^|[\s,.;:!?()\-])ма(?:й|е|я)(?=$|[\s,.;:!?()\-])|may/iu.test(normalized)) return 5;
|
||||
if (/июн|june|jun/iu.test(normalized)) return 6;
|
||||
if (/июл|july|jul/iu.test(normalized)) return 7;
|
||||
if (/август|august|aug/iu.test(normalized)) return 8;
|
||||
if (/сентябр|september|sep/iu.test(normalized)) return 9;
|
||||
if (/октябр|october|oct/iu.test(normalized)) return 10;
|
||||
if (/ноябр|november|nov/iu.test(normalized)) return 11;
|
||||
if (/декабр|december|dec/iu.test(normalized)) return 12;
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveYearFromFilters(filters: AddressFilterSet | null | undefined): number | null {
|
||||
const candidates = [
|
||||
toNonEmptyString(filters?.as_of_date),
|
||||
toNonEmptyString(filters?.period_to),
|
||||
toNonEmptyString(filters?.period_from)
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const match = candidate?.match(/\b((?:19|20)\d{2})\b/u);
|
||||
if (match) {
|
||||
const year = Number(match[1]);
|
||||
if (Number.isFinite(year)) {
|
||||
return year;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasRelativeYearHint(text: string): boolean {
|
||||
return /(?:эт(?:от|ого)(?:\s+же)?\s+год|этого\s+же\s+года|того\s+же\s+года|this\s+year|same\s+year|that\s+year)/iu.test(
|
||||
String(text ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRelativeMonthPeriodFromInventoryRoot(
|
||||
userMessage: string,
|
||||
followupContext: AddressFollowupContext | null
|
||||
): { period_from: string; period_to: string; as_of_date: string } | null {
|
||||
if (!followupContext || !isInventoryRootFrameIntent(followupContext.root_intent)) {
|
||||
return null;
|
||||
}
|
||||
const month = resolveMonthNumberFromText(userMessage);
|
||||
if (!month) {
|
||||
return null;
|
||||
}
|
||||
const normalized = String(userMessage ?? "");
|
||||
if (hasExplicitPeriodLiteral(normalized) || hasExplicitCurrentDateHint(normalized)) {
|
||||
return null;
|
||||
}
|
||||
const shortTemporalPatch = getTokenCount(normalized) <= 8 || hasRelativeYearHint(normalized);
|
||||
if (!shortTemporalPatch) {
|
||||
return null;
|
||||
}
|
||||
const year = resolveYearFromFilters(followupContext.root_filters);
|
||||
if (!year) {
|
||||
return null;
|
||||
}
|
||||
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
const periodFrom = `${year}-${String(month).padStart(2, "0")}-01`;
|
||||
const periodTo = `${year}-${String(month).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`;
|
||||
return {
|
||||
period_from: periodFrom,
|
||||
period_to: periodTo,
|
||||
as_of_date: periodTo
|
||||
};
|
||||
}
|
||||
|
||||
function shouldRestoreInventoryRootFrame(
|
||||
userMessage: string,
|
||||
intent: AddressIntent,
|
||||
extractedFilters: AddressFilterSet,
|
||||
followupContext: AddressFollowupContext | null
|
||||
): boolean {
|
||||
if (!followupContext || !isInventoryRootFrameIntent(followupContext.root_intent)) {
|
||||
return false;
|
||||
}
|
||||
const currentFrameKind = followupContext.current_frame_kind ?? null;
|
||||
const previousIntent = followupContext.previous_intent;
|
||||
const comingFromInventoryDrilldown =
|
||||
currentFrameKind === "inventory_drilldown" || isInventoryDrilldownFrameIntent(previousIntent);
|
||||
if (!comingFromInventoryDrilldown) {
|
||||
return false;
|
||||
}
|
||||
const normalized = String(userMessage ?? "");
|
||||
if (
|
||||
hasSelectedObjectInventorySignal(normalized) ||
|
||||
hasInventorySupplierFollowupCue(normalized) ||
|
||||
hasInventoryPurchaseDocumentsFollowupCue(normalized) ||
|
||||
hasInventoryPurchaseDateFollowupCue(normalized) ||
|
||||
hasBareInventoryPurchaseDateFollowupCue(normalized) ||
|
||||
hasInventorySaleFollowupCue(normalized) ||
|
||||
hasInventoryPurchaseToSaleChainFollowupCue(normalized)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (intent === "inventory_on_hand_as_of_date") {
|
||||
return true;
|
||||
}
|
||||
const hasTemporalPatch =
|
||||
hasExplicitPeriodWindow(extractedFilters) ||
|
||||
Boolean(toNonEmptyString(extractedFilters.as_of_date)) ||
|
||||
hasExplicitPeriodLiteral(normalized) ||
|
||||
Boolean(resolveRelativeMonthPeriodFromInventoryRoot(normalized, followupContext));
|
||||
return hasTemporalPatch;
|
||||
}
|
||||
|
||||
function hasSelectedObjectInventorySignal(text: string): boolean {
|
||||
return /(?:по\s+выбранному\s+объекту|for\s+selected\s+object)/iu.test(String(text ?? ""));
|
||||
}
|
||||
@@ -456,6 +636,7 @@ function mergeFollowupFilters(
|
||||
const previousAsOfDate = toNonEmptyString(previous.as_of_date);
|
||||
const previousPeriodFrom = toNonEmptyString(previous.period_from);
|
||||
const previousPeriodTo = toNonEmptyString(previous.period_to);
|
||||
const relativeMonthFromInventoryRoot = resolveRelativeMonthPeriodFromInventoryRoot(userMessage, followupContext);
|
||||
const allTimeRequested = hasAllTimeHint(userMessage);
|
||||
const sameDateRequested = hasSameDateHint(userMessage);
|
||||
if (!toNonEmptyString(merged.organization) && previousOrganization) {
|
||||
@@ -648,6 +829,15 @@ function mergeFollowupFilters(
|
||||
reasons.push("as_of_date_from_open_items_followup_context");
|
||||
}
|
||||
}
|
||||
if (
|
||||
relativeMonthFromInventoryRoot &&
|
||||
(intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date")
|
||||
) {
|
||||
merged.period_from = relativeMonthFromInventoryRoot.period_from;
|
||||
merged.period_to = relativeMonthFromInventoryRoot.period_to;
|
||||
merged.as_of_date = relativeMonthFromInventoryRoot.as_of_date;
|
||||
reasons.push("period_derived_from_inventory_root_frame_year");
|
||||
}
|
||||
if (intent === "inventory_aging_by_purchase_date") {
|
||||
const explicitItemMention = /(?:^|[\s,.;:!?()\-\u2014])(?:товар(?:у|а|ом)?|позици(?:и|я|ю)|item|row|line)(?=$|[\s,.;:!?()\-\u2014])/iu.test(
|
||||
String(userMessage ?? "")
|
||||
@@ -1016,7 +1206,8 @@ function deriveIntentWithFollowupContext(
|
||||
|
||||
export function runAddressDecomposeStage(
|
||||
userMessage: string,
|
||||
followupContext: AddressFollowupContext | null
|
||||
followupContext: AddressFollowupContext | null,
|
||||
llmSemanticHints: AddressLlmSemanticHints | null = null
|
||||
): AddressDecomposeStageResult | null {
|
||||
const detectedMode = detectAddressQuestionMode(userMessage);
|
||||
const shape = classifyAddressQueryShape(userMessage);
|
||||
@@ -1047,18 +1238,48 @@ export function runAddressDecomposeStage(
|
||||
if (mode.mode !== "address_query") {
|
||||
return null;
|
||||
}
|
||||
const intent = deriveIntentWithFollowupContext(detectedIntent, userMessage, followupContext);
|
||||
const extractedFilters = extractAddressFilters(userMessage, intent.intent);
|
||||
const followupMerged = mergeFollowupFilters(extractedFilters.extracted_filters, intent.intent, userMessage, followupContext);
|
||||
let effectiveFollowupContext = followupContext;
|
||||
let intent = deriveIntentWithFollowupContext(detectedIntent, userMessage, effectiveFollowupContext);
|
||||
let extractedFilters = applyAddressLlmSemanticHintsToExtraction(
|
||||
extractAddressFilters(userMessage, intent.intent),
|
||||
llmSemanticHints
|
||||
);
|
||||
if (
|
||||
shouldRestoreInventoryRootFrame(
|
||||
userMessage,
|
||||
intent.intent,
|
||||
extractedFilters.extracted_filters,
|
||||
effectiveFollowupContext
|
||||
)
|
||||
) {
|
||||
effectiveFollowupContext = buildInventoryRootFollowupContext(effectiveFollowupContext);
|
||||
intent = {
|
||||
intent: effectiveFollowupContext?.root_intent ?? "inventory_on_hand_as_of_date",
|
||||
confidence: "low",
|
||||
reasons: [...intent.reasons, "intent_restored_to_inventory_root_frame"]
|
||||
};
|
||||
extractedFilters = applyAddressLlmSemanticHintsToExtraction(
|
||||
extractAddressFilters(userMessage, intent.intent),
|
||||
llmSemanticHints
|
||||
);
|
||||
}
|
||||
const followupMerged = mergeFollowupFilters(
|
||||
extractedFilters.extracted_filters,
|
||||
intent.intent,
|
||||
userMessage,
|
||||
effectiveFollowupContext
|
||||
);
|
||||
const filters = {
|
||||
extracted_filters: followupMerged.filters,
|
||||
missing_required_filters: resolveMissingRequiredFilters(intent.intent, followupMerged.filters),
|
||||
warnings: [...new Set([...extractedFilters.warnings, ...followupMerged.reasons])]
|
||||
warnings: [...new Set([...extractedFilters.warnings, ...followupMerged.reasons])],
|
||||
semantic_frame: extractedFilters.semantic_frame
|
||||
};
|
||||
const followupContextApplied =
|
||||
Boolean(followupContext) &&
|
||||
Boolean(effectiveFollowupContext) &&
|
||||
(mode.reasons.includes("address_mode_from_followup_context") ||
|
||||
intent.reasons.includes("intent_from_followup_context") ||
|
||||
intent.reasons.includes("intent_restored_to_inventory_root_frame") ||
|
||||
followupMerged.reasons.length > 0);
|
||||
const baseReasons = [
|
||||
...mode.reasons,
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import type { AddressFilterSet, AddressIntent, AddressQuestionMode, AddressQueryShape } from "../../types/addressQuery";
|
||||
import type {
|
||||
AddressLlmSemanticHints,
|
||||
AddressFilterSet,
|
||||
AddressIntent,
|
||||
AddressQuestionMode,
|
||||
AddressQueryShape,
|
||||
AddressSemanticFrame
|
||||
} from "../../types/addressQuery";
|
||||
import { detectAddressQuestionMode } from "../addressQueryClassifier";
|
||||
import { classifyAddressQueryShape } from "../addressQueryShapeClassifier";
|
||||
import { resolveAddressIntent } from "../addressIntentResolver";
|
||||
import { extractAddressFilters } from "../addressFilterExtractor";
|
||||
import { applyAddressLlmSemanticHintsToExtraction } from "./semanticHintOverlay";
|
||||
|
||||
export type AddressPredecomposePeriodScope = "all_time" | "year" | "range" | "as_of" | "unspecified";
|
||||
|
||||
@@ -40,6 +48,7 @@ export interface AddressLlmPredecomposeContractV1 {
|
||||
as_of_date: string | null;
|
||||
has_explicit_period: boolean;
|
||||
};
|
||||
semantics: AddressSemanticFrame;
|
||||
aggregation_profile: AddressPredecomposeAggregationProfile;
|
||||
}
|
||||
|
||||
@@ -59,6 +68,7 @@ export interface AddressSemanticExtractionContractV1 {
|
||||
};
|
||||
entities: AddressLlmPredecomposeContractV1["entities"];
|
||||
period: AddressLlmPredecomposeContractV1["period"];
|
||||
semantics: AddressLlmPredecomposeContractV1["semantics"];
|
||||
guard_hints: {
|
||||
source_data_signal_detected: boolean;
|
||||
canonical_data_signal_detected: boolean;
|
||||
@@ -75,7 +85,7 @@ export interface AddressSemanticExtractionContractV1 {
|
||||
}
|
||||
|
||||
const ADDRESS_SEMANTIC_DATA_SIGNAL_PATTERN =
|
||||
/(?:\u0434\u043e\u043a|\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043e\u043f\u0435\u0440\u0430\u0446|\u043f\u0435\u0440\u0438\u043e\u0434|\u0433\u043e\u0434|counterparty|contract|document|account|balance|turnover|operations?|doki|doky|dokument|dogovor|kontragent|schet|saldo|platezh|oplata)/iu;
|
||||
/(?:\u0434\u043e\u043a|\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043e\u043f\u0435\u0440\u0430\u0446|\u043f\u0435\u0440\u0438\u043e\u0434|\u0433\u043e\u0434|\u0441\u043a\u043b\u0430\u0434|\u0442\u043e\u0432\u0430\u0440|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440|counterparty|contract|document|account|balance|turnover|operations?|warehouse|stock|inventory|item|goods|doki|doky|dokument|dogovor|kontragent|schet|saldo|platezh|oplata)/iu;
|
||||
|
||||
const ADDRESS_SEMANTIC_ENTITY_SIGNAL_PATTERN =
|
||||
/(?:\u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043a\u043e\u043c\u043f\u0430\u043d|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u043a\u043e\u043d\u0442\u043e\u0440|customer|supplier|counterparty|company|vendor|client)/iu;
|
||||
@@ -84,7 +94,7 @@ const ADDRESS_SEMANTIC_SCOPE_META_PATTERN =
|
||||
/(?:\u043a\u0430\u043a\u0430\u044f\s+\u0431\u0430\u0437\u0430|\u0431\u0430\u0437\u0430\s+\u043a\u0430\u043a\u043e\u0439\s+\u043a\u043e\u043d\u0442\u043e\u0440|\u043f\u043e\s+\u043a\u0430\u043a\u0438\u043c\s+\u043a\u043e\u043d\u0442\u043e\u0440|which\s+company\s+base|which\s+tenant|data\s+scope)/iu;
|
||||
|
||||
const ADDRESS_SEMANTIC_DEEP_INVESTIGATION_PATTERN =
|
||||
/(?:\u043f\u0440\u043e\u0432\u0435\u0440(?:\u044c|\u0438\u0442\u044c)|\u0440\u0430\u0437\u0431\u0435\u0440(?:\u0438|\u0430\u0442\u044c)|\u043f\u043e\u0447\u0435\u043c\u0443|\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c|\u0440\u0430\u0437\u0440\u044b\u0432|\u0445\u0432\u043e\u0441\u0442|root\s*cause|trace\s*chain|state\s+transition)/iu;
|
||||
/(?:\u043f\u043e\u0447\u0435\u043c\u0443|\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c|\u0440\u0430\u0437\u0440\u044b\u0432|\u0445\u0432\u043e\u0441\u0442|root\s*cause|trace\s*chain|state\s+transition|\u043f\u0440\u043e\u0432\u0435\u0440(?:\u044c|\u0438\u0442\u044c).*(?:\u0445\u0432\u043e\u0441\u0442|\u0440\u0430\u0437\u0440\u044b\u0432|\u0437\u0430\u043a\u0440\u044b\u0442|\u0446\u0435\u043f\u043e\u0447|\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c|\u043e\u0448\u0438\u0431|\u0430\u043d\u043e\u043c\u0430\u043b|\u0440\u0438\u0441\u043a|\u0441\u0432\u0435\u0440\u043a)|\u0440\u0430\u0437\u0431\u0435\u0440(?:\u0438|\u0430\u0442\u044c).*(?:\u043f\u043e\u0447\u0435\u043c\u0443|\u0445\u0432\u043e\u0441\u0442|\u0440\u0430\u0437\u0440\u044b\u0432|\u0437\u0430\u043a\u0440\u044b\u0442|\u0446\u0435\u043f\u043e\u0447|\u043e\u0448\u0438\u0431|\u0430\u043d\u043e\u043c\u0430\u043b|\u0440\u0438\u0441\u043a))/iu;
|
||||
|
||||
function normalizeCompact(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
@@ -232,6 +242,7 @@ function inferAggregationProfile(intent: AddressIntent, shape: AddressQueryShape
|
||||
export function buildAddressLlmPredecomposeContractV1(input: {
|
||||
sourceMessage: string;
|
||||
canonicalMessage: string;
|
||||
semanticHints?: AddressLlmSemanticHints | null;
|
||||
}): AddressLlmPredecomposeContractV1 {
|
||||
const sourceMessage = String(input.sourceMessage ?? "").trim();
|
||||
const canonicalMessage = String(input.canonicalMessage ?? "").trim() || sourceMessage;
|
||||
@@ -239,8 +250,20 @@ export function buildAddressLlmPredecomposeContractV1(input: {
|
||||
const mode = detectAddressQuestionMode(canonicalMessage);
|
||||
const shape = classifyAddressQueryShape(canonicalMessage);
|
||||
const intent = resolveAddressIntent(canonicalMessage);
|
||||
const extraction = extractAddressFilters(canonicalMessage, intent.intent);
|
||||
const extraction = applyAddressLlmSemanticHintsToExtraction(
|
||||
extractAddressFilters(canonicalMessage, intent.intent),
|
||||
input.semanticHints ?? null
|
||||
);
|
||||
const filters = extraction.extracted_filters;
|
||||
const semanticFrame: AddressSemanticFrame = extraction.semantic_frame ?? {
|
||||
scope_kind: "none",
|
||||
anchor_kind: "none",
|
||||
anchor_value: null,
|
||||
date_scope_kind: "none",
|
||||
date_basis_hint: null,
|
||||
self_scope_detected: false,
|
||||
selected_object_scope_detected: false
|
||||
};
|
||||
const periodScope = inferPeriodScope(filters, canonicalMessage);
|
||||
|
||||
return {
|
||||
@@ -266,10 +289,9 @@ export function buildAddressLlmPredecomposeContractV1(input: {
|
||||
period_from: toNonEmptyString(filters.period_from),
|
||||
period_to: toNonEmptyString(filters.period_to),
|
||||
as_of_date: toNonEmptyString(filters.as_of_date),
|
||||
has_explicit_period: Boolean(
|
||||
toNonEmptyString(filters.as_of_date) || toNonEmptyString(filters.period_from) || toNonEmptyString(filters.period_to)
|
||||
)
|
||||
has_explicit_period: semanticFrame.date_scope_kind === "explicit"
|
||||
},
|
||||
semantics: semanticFrame,
|
||||
aggregation_profile: inferAggregationProfile(intent.intent, shape.shape)
|
||||
};
|
||||
}
|
||||
@@ -370,6 +392,7 @@ export function buildAddressSemanticExtractionContractV1(input: {
|
||||
as_of_date: predecomposeContract.period.as_of_date,
|
||||
has_explicit_period: predecomposeContract.period.has_explicit_period
|
||||
},
|
||||
semantics: predecomposeContract.semantics,
|
||||
guard_hints: {
|
||||
source_data_signal_detected: sourceDataSignal,
|
||||
canonical_data_signal_detected: canonicalDataSignal,
|
||||
|
||||
@@ -16,7 +16,16 @@ const PARTY_ANCHOR_STOPWORDS = new Set([
|
||||
]);
|
||||
|
||||
export interface AnchorResolutionDebug {
|
||||
anchor_type: "account" | "counterparty" | "contract" | "document_ref" | "item" | "warehouse" | "unknown" | null;
|
||||
anchor_type:
|
||||
| "account"
|
||||
| "counterparty"
|
||||
| "contract"
|
||||
| "document_ref"
|
||||
| "item"
|
||||
| "warehouse"
|
||||
| "organization"
|
||||
| "unknown"
|
||||
| null;
|
||||
anchor_value_raw: string | null;
|
||||
anchor_value_resolved: string | null;
|
||||
resolver_confidence: "high" | "medium" | "low" | null;
|
||||
@@ -30,6 +39,7 @@ export interface ResolveStageRow {
|
||||
analytics: string[];
|
||||
item?: string | null;
|
||||
warehouse?: string | null;
|
||||
organization?: string | null;
|
||||
}
|
||||
|
||||
function transliterateCyrillicToLatin(value: string): string {
|
||||
@@ -175,6 +185,7 @@ export function resolvePrimaryAnchor(intent: AddressIntent, filters: AddressFilt
|
||||
const contract = typeof filters.contract === "string" ? filters.contract.trim() : "";
|
||||
const item = typeof filters.item === "string" ? filters.item.trim() : "";
|
||||
const warehouse = typeof filters.warehouse === "string" ? filters.warehouse.trim() : "";
|
||||
const organization = typeof filters.organization === "string" ? filters.organization.trim() : "";
|
||||
const documentRef = typeof filters.document_ref === "string" ? filters.document_ref.trim() : "";
|
||||
|
||||
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
|
||||
@@ -260,6 +271,16 @@ export function resolvePrimaryAnchor(intent: AddressIntent, filters: AddressFilt
|
||||
};
|
||||
}
|
||||
|
||||
if (organization) {
|
||||
return {
|
||||
anchor_type: "organization",
|
||||
anchor_value_raw: organization,
|
||||
anchor_value_resolved: organization,
|
||||
resolver_confidence: "medium",
|
||||
ambiguity_count: 0
|
||||
};
|
||||
}
|
||||
|
||||
if (documentRef) {
|
||||
return {
|
||||
anchor_type: "document_ref",
|
||||
@@ -287,7 +308,8 @@ export function refineAnchorFromRows(anchor: AnchorResolutionDebug, rows: Resolv
|
||||
anchor.anchor_type !== "counterparty" &&
|
||||
anchor.anchor_type !== "contract" &&
|
||||
anchor.anchor_type !== "item" &&
|
||||
anchor.anchor_type !== "warehouse"
|
||||
anchor.anchor_type !== "warehouse" &&
|
||||
anchor.anchor_type !== "organization"
|
||||
) {
|
||||
return anchor;
|
||||
}
|
||||
@@ -296,8 +318,16 @@ export function refineAnchorFromRows(anchor: AnchorResolutionDebug, rows: Resolv
|
||||
return anchor;
|
||||
}
|
||||
const searchableRows =
|
||||
anchor.anchor_type === "item" || anchor.anchor_type === "warehouse"
|
||||
? rows.flatMap((row) => [row.registrator, row.item ?? "", row.warehouse ?? "", row.account_dt ?? "", row.account_kt ?? "", ...row.analytics])
|
||||
anchor.anchor_type === "item" || anchor.anchor_type === "warehouse" || anchor.anchor_type === "organization"
|
||||
? rows.flatMap((row) => [
|
||||
row.registrator,
|
||||
row.item ?? "",
|
||||
row.warehouse ?? "",
|
||||
row.organization ?? "",
|
||||
row.account_dt ?? "",
|
||||
row.account_kt ?? "",
|
||||
...row.analytics
|
||||
])
|
||||
: rows.flatMap((row) => row.analytics);
|
||||
const candidates = uniqueStrings(
|
||||
searchableRows
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import type {
|
||||
AddressAsOfDateBasis,
|
||||
AddressFilterExtraction,
|
||||
AddressLlmSemanticHints,
|
||||
AddressSemanticFrame
|
||||
} from "../../types/addressQuery";
|
||||
|
||||
function toNonEmptyString(value: unknown): string | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
const normalized = String(value).trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeToken(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "_");
|
||||
}
|
||||
|
||||
export function normalizeAddressLlmSemanticHints(value: unknown): AddressLlmSemanticHints | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
const source = value as Record<string, unknown>;
|
||||
const scopeToken = normalizeToken(source.scope_target_kind);
|
||||
const dateToken = normalizeToken(source.date_scope_kind);
|
||||
|
||||
const scopeTargetKind: AddressLlmSemanticHints["scope_target_kind"] =
|
||||
scopeToken === "self_scope" ||
|
||||
scopeToken === "selected_object" ||
|
||||
scopeToken === "organization" ||
|
||||
scopeToken === "warehouse" ||
|
||||
scopeToken === "counterparty" ||
|
||||
scopeToken === "contract" ||
|
||||
scopeToken === "item"
|
||||
? (scopeToken as AddressLlmSemanticHints["scope_target_kind"])
|
||||
: "none";
|
||||
|
||||
const dateScopeKind: AddressLlmSemanticHints["date_scope_kind"] =
|
||||
dateToken === "explicit" || dateToken === "implicit_current" ? (dateToken as AddressLlmSemanticHints["date_scope_kind"]) : "missing";
|
||||
|
||||
return {
|
||||
scope_target_kind: scopeTargetKind,
|
||||
scope_target_text: toNonEmptyString(source.scope_target_text),
|
||||
date_scope_kind: dateScopeKind,
|
||||
self_scope_detected: source.self_scope_detected === true || scopeTargetKind === "self_scope",
|
||||
selected_object_scope_detected:
|
||||
source.selected_object_scope_detected === true || scopeTargetKind === "selected_object"
|
||||
};
|
||||
}
|
||||
|
||||
function defaultSemanticFrame(extraction: AddressFilterExtraction): AddressSemanticFrame {
|
||||
return (
|
||||
extraction.semantic_frame ?? {
|
||||
scope_kind: "none",
|
||||
anchor_kind: "none",
|
||||
anchor_value: null,
|
||||
date_scope_kind: "none",
|
||||
date_basis_hint: null,
|
||||
self_scope_detected: false,
|
||||
selected_object_scope_detected: false
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function pushWarning(warnings: string[], value: string): void {
|
||||
if (!warnings.includes(value)) {
|
||||
warnings.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
function applyDateScopeHint(frame: AddressSemanticFrame, dateScopeKind: AddressLlmSemanticHints["date_scope_kind"]): void {
|
||||
if (dateScopeKind === "explicit") {
|
||||
frame.date_scope_kind = "explicit";
|
||||
return;
|
||||
}
|
||||
if (dateScopeKind === "implicit_current" && frame.date_scope_kind !== "explicit") {
|
||||
frame.date_scope_kind = "implicit_current";
|
||||
frame.date_basis_hint = "implicit_current_snapshot" satisfies AddressAsOfDateBasis;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyAddressLlmSemanticHintsToExtraction(
|
||||
extraction: AddressFilterExtraction,
|
||||
semanticHintsInput: unknown
|
||||
): AddressFilterExtraction {
|
||||
const semanticHints = normalizeAddressLlmSemanticHints(semanticHintsInput);
|
||||
if (!semanticHints) {
|
||||
return extraction;
|
||||
}
|
||||
|
||||
const extractedFilters = { ...(extraction.extracted_filters ?? {}) };
|
||||
const warnings = [...(Array.isArray(extraction.warnings) ? extraction.warnings : [])];
|
||||
const semanticFrame = { ...defaultSemanticFrame(extraction) };
|
||||
const scopeTargetText = semanticHints.scope_target_text;
|
||||
|
||||
applyDateScopeHint(semanticFrame, semanticHints.date_scope_kind);
|
||||
|
||||
if (semanticHints.self_scope_detected) {
|
||||
semanticFrame.scope_kind = "implicit_self_scope";
|
||||
semanticFrame.anchor_kind = "self_scope";
|
||||
semanticFrame.anchor_value = null;
|
||||
semanticFrame.self_scope_detected = true;
|
||||
}
|
||||
|
||||
if (semanticHints.selected_object_scope_detected) {
|
||||
if (semanticFrame.scope_kind === "none") {
|
||||
semanticFrame.scope_kind = "selected_object_scope";
|
||||
semanticFrame.anchor_kind = "selected_object";
|
||||
semanticFrame.anchor_value = null;
|
||||
}
|
||||
semanticFrame.selected_object_scope_detected = true;
|
||||
}
|
||||
|
||||
if (semanticHints.scope_target_kind === "organization" && scopeTargetText) {
|
||||
extractedFilters.organization = scopeTargetText;
|
||||
pushWarning(warnings, "organization_from_llm_semantics");
|
||||
if (toNonEmptyString(extractedFilters.warehouse)) {
|
||||
delete extractedFilters.warehouse;
|
||||
pushWarning(warnings, "warehouse_cleared_by_llm_organization_semantics");
|
||||
}
|
||||
semanticFrame.scope_kind = "explicit_anchor";
|
||||
semanticFrame.anchor_kind = "organization";
|
||||
semanticFrame.anchor_value = scopeTargetText;
|
||||
}
|
||||
|
||||
if (semanticHints.scope_target_kind === "warehouse" && scopeTargetText) {
|
||||
extractedFilters.warehouse = scopeTargetText;
|
||||
pushWarning(warnings, "warehouse_from_llm_semantics");
|
||||
semanticFrame.scope_kind = "explicit_anchor";
|
||||
semanticFrame.anchor_kind = "warehouse";
|
||||
semanticFrame.anchor_value = scopeTargetText;
|
||||
}
|
||||
|
||||
if (semanticHints.scope_target_kind === "counterparty" && scopeTargetText) {
|
||||
extractedFilters.counterparty = scopeTargetText;
|
||||
pushWarning(warnings, "counterparty_from_llm_semantics");
|
||||
semanticFrame.scope_kind = "explicit_anchor";
|
||||
semanticFrame.anchor_kind = "counterparty";
|
||||
semanticFrame.anchor_value = scopeTargetText;
|
||||
}
|
||||
|
||||
if (semanticHints.scope_target_kind === "contract" && scopeTargetText) {
|
||||
extractedFilters.contract = scopeTargetText;
|
||||
pushWarning(warnings, "contract_from_llm_semantics");
|
||||
semanticFrame.scope_kind = "explicit_anchor";
|
||||
semanticFrame.anchor_kind = "contract";
|
||||
semanticFrame.anchor_value = scopeTargetText;
|
||||
}
|
||||
|
||||
if (semanticHints.scope_target_kind === "item" && scopeTargetText) {
|
||||
extractedFilters.item = scopeTargetText;
|
||||
pushWarning(warnings, "item_from_llm_semantics");
|
||||
semanticFrame.scope_kind = "explicit_anchor";
|
||||
semanticFrame.anchor_kind = "item";
|
||||
semanticFrame.anchor_value = scopeTargetText;
|
||||
}
|
||||
|
||||
return {
|
||||
...extraction,
|
||||
extracted_filters: extractedFilters,
|
||||
warnings,
|
||||
semantic_frame: semanticFrame
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user