NODEDC_1C/llm_normalizer/backend/src/services/addressNavigationState.ts

518 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { nanoid } from "nanoid";
import type { AssistantConversationItem } from "../types/assistant";
import type { AddressIntent } from "../types/addressQuery";
import {
ADDRESS_NAVIGATION_STATE_SCHEMA_VERSION,
type AddressFocusObject,
type AddressFocusObjectType,
type AddressNavigationAction,
type AddressNavigationEvent,
type AddressNavigationState,
type AddressResultEntityRef,
type AddressResultSet,
type AddressResultSetType
} from "../types/addressNavigation";
const MAX_RESULT_SETS = 40;
const MAX_NAVIGATION_EVENTS = 120;
const MAX_ENTITY_REFS_PER_RESULT_SET = 40;
const DISPLAY_ENTITY_TYPE_BY_INTENT: Partial<Record<AddressIntent, AddressFocusObjectType>> = {
counterparty_activity_lifecycle: "counterparty",
customer_revenue_and_payments: "counterparty",
supplier_payouts_profile: "counterparty",
list_payables_counterparties: "counterparty",
receivables_confirmed_as_of_date: "counterparty",
list_receivables_counterparties: "counterparty",
list_contracts_by_counterparty: "contract",
list_documents_by_counterparty: "document_ref",
list_documents_by_contract: "document_ref",
bank_operations_by_counterparty: "document_ref",
bank_operations_by_contract: "document_ref",
open_items_by_counterparty_or_contract: "counterparty",
inventory_on_hand_as_of_date: "item",
inventory_purchase_provenance_for_item: "item",
inventory_purchase_documents_for_item: "item",
inventory_supplier_stock_overlap_as_of_date: "item",
inventory_sale_trace_for_item: "item",
inventory_purchase_to_sale_chain: "item",
inventory_aging_by_purchase_date: "item"
};
const RESULT_SET_TYPE_BY_INTENT: Partial<Record<AddressIntent, AddressResultSetType>> = {
counterparty_activity_lifecycle: "counterparty_list",
customer_revenue_and_payments: "counterparty_list",
supplier_payouts_profile: "counterparty_list",
list_payables_counterparties: "counterparty_list",
payables_confirmed_as_of_date: "balance_snapshot",
vat_payable_confirmed_as_of_date: "balance_snapshot",
vat_liability_confirmed_for_tax_period: "balance_snapshot",
receivables_confirmed_as_of_date: "balance_snapshot",
list_receivables_counterparties: "counterparty_list",
list_contracts_by_counterparty: "contract_list",
list_documents_by_counterparty: "document_list",
list_documents_by_contract: "document_list",
bank_operations_by_counterparty: "bank_operations_list",
bank_operations_by_contract: "bank_operations_list",
open_items_by_counterparty_or_contract: "open_items_list",
inventory_on_hand_as_of_date: "inventory_snapshot",
inventory_purchase_provenance_for_item: "inventory_trace",
inventory_purchase_documents_for_item: "inventory_trace",
inventory_supplier_stock_overlap_as_of_date: "inventory_trace",
inventory_sale_trace_for_item: "inventory_trace",
inventory_purchase_to_sale_chain: "inventory_trace",
inventory_aging_by_purchase_date: "inventory_trace",
period_coverage_profile: "profile_summary",
document_type_and_account_section_profile: "profile_summary",
counterparty_population_and_roles: "profile_summary",
contract_usage_overview: "profile_summary",
contract_usage_and_value: "profile_summary",
vat_payable_forecast: "profile_summary"
};
function toObject(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function toNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function toAddressFocusObjectType(value: unknown): AddressFocusObjectType {
const normalized = toNonEmptyString(value);
if (!normalized) {
return "unknown";
}
if (
normalized === "counterparty" ||
normalized === "contract" ||
normalized === "document_ref" ||
normalized === "account" ||
normalized === "item" ||
normalized === "organization" ||
normalized === "warehouse"
) {
return normalized;
}
return "unknown";
}
function toAddressIntent(value: unknown): AddressIntent {
const normalized = toNonEmptyString(value);
return (normalized ?? "unknown") as AddressIntent;
}
function inferDisplayEntityType(intent: AddressIntent): AddressFocusObjectType {
return DISPLAY_ENTITY_TYPE_BY_INTENT[intent] ?? "unknown";
}
function inferResultSetType(intent: AddressIntent): AddressResultSetType {
return RESULT_SET_TYPE_BY_INTENT[intent] ?? "unknown";
}
function parseEntityCandidateFromLine(line: string): { index: number; value: string } | null {
const compact = String(line ?? "").trim();
if (!compact) {
return null;
}
const numberedMatch = compact.match(/^(\d+)\.\s+(.+)$/);
if (!numberedMatch) {
return null;
}
const index = Number.parseInt(String(numberedMatch[1] ?? ""), 10);
if (!Number.isFinite(index) || index <= 0) {
return null;
}
const afterNumber = String(numberedMatch[2] ?? "");
const pieces = afterNumber.split("|").map((item) => item.trim()).filter(Boolean);
const valueCandidate = pieces.length > 0 ? pieces[0] : afterNumber;
const cleaned = valueCandidate.replace(/^["'«»`]+|["'«»`]+$/gu, "").trim();
if (!cleaned || cleaned.length < 2) {
return null;
}
return { index, value: cleaned };
}
function extractEntityRefsFromAssistantReply(
replyText: string,
intent: AddressIntent,
limit: number = MAX_ENTITY_REFS_PER_RESULT_SET
): AddressResultEntityRef[] {
const entityType = inferDisplayEntityType(intent);
if (entityType === "unknown") {
return [];
}
const dedup = new Map<string, AddressResultEntityRef>();
const lines = String(replyText ?? "").split(/\r?\n/);
for (const line of lines) {
const parsed = parseEntityCandidateFromLine(line);
if (!parsed) {
continue;
}
const key = `${parsed.index}:${entityType}:${parsed.value.toLowerCase()}`;
if (!dedup.has(key)) {
dedup.set(key, {
index: parsed.index,
entity_type: entityType,
value: parsed.value
});
}
if (dedup.size >= limit) {
break;
}
}
return Array.from(dedup.values());
}
function extractOrganizationsFromAssistantReply(replyText: string, limit: number = 10): string[] {
const dedup = new Map<string, string>();
const lines = String(replyText ?? "").split(/\r?\n/);
for (const line of lines) {
const match = line.match(/(?:^|\|)\s*организац(?:ия|ии)\s*:\s*([^|]+)/iu);
if (!match) {
continue;
}
const organization = toNonEmptyString(match[1]);
if (!organization) {
continue;
}
const key = organization.toLowerCase();
if (!dedup.has(key)) {
dedup.set(key, organization);
}
if (dedup.size >= limit) {
break;
}
}
return Array.from(dedup.values());
}
function resolveDerivedOrganizationScope(
debug: Record<string, unknown>,
filters: Record<string, unknown>,
replyText: string
): string | null {
const rootFrameContext = toObject(debug.address_root_frame_context) ?? {};
const candidates = [
toNonEmptyString(filters.organization),
toNonEmptyString(rootFrameContext.organization),
...extractOrganizationsFromAssistantReply(replyText)
].filter((value): value is string => Boolean(value));
const dedup = Array.from(new Map(candidates.map((value) => [value.toLowerCase(), value])).values());
return dedup.length === 1 ? dedup[0] : null;
}
function cloneFocusObject(value: AddressFocusObject | null): AddressFocusObject | null {
if (!value) {
return null;
}
return {
object_type: value.object_type,
object_id: value.object_id,
label: value.label,
provenance_result_set_id: value.provenance_result_set_id,
selected_at: value.selected_at
};
}
function cloneResultSet(input: AddressResultSet): AddressResultSet {
return {
result_set_id: input.result_set_id,
type: input.type,
intent: input.intent,
route_id: input.route_id,
filters: { ...input.filters },
source_refs: [...input.source_refs],
entity_refs: input.entity_refs.map((item) => ({
index: item.index,
entity_type: item.entity_type,
value: item.value
})),
created_from_turn: input.created_from_turn,
created_at: input.created_at
};
}
function cloneNavigationEvent(input: AddressNavigationEvent): AddressNavigationEvent {
return {
event_id: input.event_id,
action: input.action,
source_result_set_id: input.source_result_set_id,
target_object_id: input.target_object_id,
derived_result_set_id: input.derived_result_set_id,
turn_index: input.turn_index,
created_at: input.created_at
};
}
function normalizeFilters(value: unknown): Record<string, unknown> {
const record = toObject(value);
if (!record) {
return {};
}
return { ...record };
}
function resolveNavigationAction(debug: Record<string, unknown>, hasFocusObject: boolean): AddressNavigationAction {
const continuationContract = toObject(debug.dialog_continuation_contract_v2);
const decision = toNonEmptyString(continuationContract?.decision);
if (decision === "new_topic") {
return "open";
}
if (decision === "continue_previous") {
return hasFocusObject ? "drilldown" : "refine";
}
if (decision === "switch_to_suggested") {
return "refine";
}
return hasFocusObject ? "drilldown" : "open";
}
function buildFocusObjectFromDebug(debug: Record<string, unknown>, resultSetId: string, createdAt: string): AddressFocusObject | null {
const extractedFilters = toObject(debug.extracted_filters) ?? {};
const rawValue =
toNonEmptyString(debug.anchor_value_resolved) ??
toNonEmptyString(debug.anchor_value_raw) ??
toNonEmptyString(extractedFilters.item);
if (!rawValue) {
return null;
}
const objectType = toAddressFocusObjectType(debug.anchor_type);
const canonicalType = objectType === "unknown" ? inferDisplayEntityType(toAddressIntent(debug.detected_intent)) : objectType;
return {
object_type: canonicalType,
object_id: `${canonicalType}:${rawValue}`.toLowerCase(),
label: rawValue,
provenance_result_set_id: resultSetId,
selected_at: createdAt
};
}
function capResultSets(resultSets: AddressResultSet[]): AddressResultSet[] {
if (resultSets.length <= MAX_RESULT_SETS) {
return resultSets;
}
return resultSets.slice(resultSets.length - MAX_RESULT_SETS);
}
function capNavigationEvents(events: AddressNavigationEvent[]): AddressNavigationEvent[] {
if (events.length <= MAX_NAVIGATION_EVENTS) {
return events;
}
return events.slice(events.length - MAX_NAVIGATION_EVENTS);
}
function isAddressAssistantItem(item: AssistantConversationItem): boolean {
return (
item.role === "assistant" &&
Boolean(item.debug) &&
toNonEmptyString(item.debug?.detected_mode) === "address_query"
);
}
export function createEmptyAddressNavigationState(
sessionId: string,
nowIso: string = new Date().toISOString()
): AddressNavigationState {
return {
schema_version: ADDRESS_NAVIGATION_STATE_SCHEMA_VERSION,
session_id: sessionId,
updated_at: nowIso,
session_context: {
active_result_set_id: null,
active_focus_object: null,
last_confirmed_route: null,
date_scope: {
as_of_date: null,
period_from: null,
period_to: null
},
organization_scope: null
},
result_sets: [],
navigation_history: []
};
}
export function cloneAddressNavigationState(value: AddressNavigationState | null): AddressNavigationState | null {
if (!value) {
return null;
}
return {
schema_version: value.schema_version,
session_id: value.session_id,
updated_at: value.updated_at,
session_context: {
active_result_set_id: value.session_context.active_result_set_id,
active_focus_object: cloneFocusObject(value.session_context.active_focus_object),
last_confirmed_route: value.session_context.last_confirmed_route,
date_scope: {
as_of_date: value.session_context.date_scope.as_of_date,
period_from: value.session_context.date_scope.period_from,
period_to: value.session_context.date_scope.period_to
},
organization_scope: value.session_context.organization_scope
},
result_sets: value.result_sets.map(cloneResultSet),
navigation_history: value.navigation_history.map(cloneNavigationEvent)
};
}
export function normalizeAddressNavigationState(
value: AddressNavigationState | null | undefined,
sessionId: string
): AddressNavigationState {
const fallback = createEmptyAddressNavigationState(sessionId);
if (!value || typeof value !== "object") {
return fallback;
}
const normalizedSessionId = toNonEmptyString(value.session_id) ?? sessionId;
const normalizedUpdatedAt = toNonEmptyString(value.updated_at) ?? new Date().toISOString();
const context = toObject(value.session_context) ?? {};
const dateScope = toObject(context.date_scope) ?? {};
const resultSets = Array.isArray(value.result_sets) ? value.result_sets : [];
const navigationHistory = Array.isArray(value.navigation_history) ? value.navigation_history : [];
return {
schema_version: ADDRESS_NAVIGATION_STATE_SCHEMA_VERSION,
session_id: normalizedSessionId,
updated_at: normalizedUpdatedAt,
session_context: {
active_result_set_id: toNonEmptyString(context.active_result_set_id),
active_focus_object: cloneFocusObject(context.active_focus_object as AddressFocusObject | null),
last_confirmed_route: toNonEmptyString(context.last_confirmed_route),
date_scope: {
as_of_date: toNonEmptyString(dateScope.as_of_date),
period_from: toNonEmptyString(dateScope.period_from),
period_to: toNonEmptyString(dateScope.period_to)
},
organization_scope: toNonEmptyString(context.organization_scope)
},
result_sets: resultSets
.map((item) => toObject(item))
.filter((item): item is Record<string, unknown> => item !== null)
.map((item) => ({
result_set_id: toNonEmptyString(item.result_set_id) ?? `rs-${nanoid(10)}`,
type: inferResultSetType(toAddressIntent(item.intent)),
intent: toAddressIntent(item.intent),
route_id: toNonEmptyString(item.route_id),
filters: normalizeFilters(item.filters),
source_refs: Array.isArray(item.source_refs)
? item.source_refs.map((v) => toNonEmptyString(v)).filter((v): v is string => Boolean(v))
: [],
entity_refs: Array.isArray(item.entity_refs)
? item.entity_refs
.map((entry) => toObject(entry))
.filter((entry): entry is Record<string, unknown> => entry !== null)
.map((entry) => ({
index: Number.isFinite(Number(entry.index)) ? Number(entry.index) : 0,
entity_type: toAddressFocusObjectType(entry.entity_type),
value: toNonEmptyString(entry.value) ?? ""
}))
.filter((entry) => entry.index > 0 && entry.value.length > 0)
: [],
created_from_turn: Number.isFinite(Number(item.created_from_turn)) ? Number(item.created_from_turn) : 0,
created_at: toNonEmptyString(item.created_at) ?? normalizedUpdatedAt
})),
navigation_history: navigationHistory
.map((item) => toObject(item))
.filter((item): item is Record<string, unknown> => item !== null)
.map((item) => ({
event_id: toNonEmptyString(item.event_id) ?? `nav-${nanoid(10)}`,
action: (toNonEmptyString(item.action) as AddressNavigationAction | null) ?? "open",
source_result_set_id: toNonEmptyString(item.source_result_set_id),
target_object_id: toNonEmptyString(item.target_object_id),
derived_result_set_id: toNonEmptyString(item.derived_result_set_id),
turn_index: Number.isFinite(Number(item.turn_index)) ? Number(item.turn_index) : 0,
created_at: toNonEmptyString(item.created_at) ?? normalizedUpdatedAt
}))
};
}
export function evolveAddressNavigationStateWithAssistantItem(
state: AddressNavigationState,
item: AssistantConversationItem,
turnIndex: number
): AddressNavigationState {
if (!isAddressAssistantItem(item) || !item.debug) {
return state;
}
const debug = item.debug as unknown as Record<string, unknown>;
const intent = toAddressIntent(debug.detected_intent);
if (intent === "unknown") {
return state;
}
const createdAt = toNonEmptyString(item.created_at) ?? new Date().toISOString();
const resultSetId = `rs-${item.message_id}`;
const routeId = toNonEmptyString(debug.selected_recipe);
const filters = normalizeFilters(debug.extracted_filters);
const derivedOrganizationScope = resolveDerivedOrganizationScope(debug, filters, item.text);
const filtersWithDerivedScope =
derivedOrganizationScope && !toNonEmptyString(filters.organization)
? {
...filters,
organization: derivedOrganizationScope
}
: filters;
const sourceRefs = routeId ? [routeId] : [];
const entityRefs = extractEntityRefsFromAssistantReply(item.text, intent);
const resultSet: AddressResultSet = {
result_set_id: resultSetId,
type: inferResultSetType(intent),
intent,
route_id: routeId,
filters: filtersWithDerivedScope,
source_refs: sourceRefs,
entity_refs: entityRefs,
created_from_turn: turnIndex,
created_at: createdAt
};
const previousResultSetId = state.session_context.active_result_set_id;
const focusObject = buildFocusObjectFromDebug(debug, resultSetId, createdAt);
const action = resolveNavigationAction(debug, Boolean(focusObject));
const navigationEvent: AddressNavigationEvent = {
event_id: `nav-${nanoid(10)}`,
action,
source_result_set_id: previousResultSetId,
target_object_id: focusObject?.object_id ?? null,
derived_result_set_id: resultSetId,
turn_index: turnIndex,
created_at: createdAt
};
const normalizedDateScope = {
as_of_date: toNonEmptyString(filtersWithDerivedScope.as_of_date),
period_from: toNonEmptyString(filtersWithDerivedScope.period_from),
period_to: toNonEmptyString(filtersWithDerivedScope.period_to)
};
const organizationScope = toNonEmptyString(filtersWithDerivedScope.organization);
const nextResultSets = capResultSets(
[...state.result_sets.filter((itemSet) => itemSet.result_set_id !== resultSetId), resultSet].sort(
(left, right) => left.created_from_turn - right.created_from_turn
)
);
const nextEvents = capNavigationEvents([...state.navigation_history, navigationEvent]);
return {
...state,
updated_at: createdAt,
session_context: {
active_result_set_id: resultSetId,
active_focus_object: focusObject ?? state.session_context.active_focus_object,
last_confirmed_route: routeId ?? state.session_context.last_confirmed_route,
date_scope: {
as_of_date: normalizedDateScope.as_of_date ?? state.session_context.date_scope.as_of_date,
period_from: normalizedDateScope.period_from ?? state.session_context.date_scope.period_from,
period_to: normalizedDateScope.period_to ?? state.session_context.date_scope.period_to
},
organization_scope: organizationScope ?? state.session_context.organization_scope
},
result_sets: nextResultSets,
navigation_history: nextEvents
};
}