АРЧ - Ассистент: отделить meta-followup по прошлому ответу от повторного запуска address lane

This commit is contained in:
2026-04-15 12:38:44 +03:00
parent 8866176be6
commit 70cc5a99f1
61 changed files with 4023 additions and 298 deletions
@@ -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
};
}