ОРРКЕСТРАЦИЯ - Оркестрация домена: ужесточить автофикс loop и назначать primary repair focus

This commit is contained in:
2026-04-15 08:09:42 +03:00
parent 5934f5f3fc
commit bc381c012e
16 changed files with 956 additions and 38 deletions
@@ -13,7 +13,7 @@ import { extractAddressFilters } from "../addressFilterExtractor";
export interface AddressFollowupContext {
previous_intent?: AddressIntent;
previous_filters?: AddressFilterSet;
previous_anchor_type?: "account" | "counterparty" | "contract" | "document_ref" | "unknown" | null;
previous_anchor_type?: "account" | "counterparty" | "contract" | "document_ref" | "item" | "warehouse" | "unknown" | null;
previous_anchor_value?: string | null;
resolved_counterparty_from_display?: boolean;
}
@@ -451,6 +451,7 @@ function mergeFollowupFilters(
const previousContract = toNonEmptyString(previous.contract);
const previousAccount = toNonEmptyString(previous.account);
const previousItem = toNonEmptyString(previous.item);
const previousAnchorItem = followupContext.previous_anchor_type === "item" ? previousAnchorValue : null;
const previousOrganization = toNonEmptyString(previous.organization);
const previousAsOfDate = toNonEmptyString(previous.as_of_date);
const previousPeriodFrom = toNonEmptyString(previous.period_from);
@@ -587,11 +588,11 @@ function mergeFollowupFilters(
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date") &&
!toNonEmptyString(merged.item) &&
previousItem
!toNonEmptyString(merged.item)
) {
if (intent !== "inventory_aging_by_purchase_date") {
merged.item = previousItem;
const inheritedItem = previousItem ?? previousAnchorItem;
if (inheritedItem && intent !== "inventory_aging_by_purchase_date") {
merged.item = inheritedItem;
reasons.push("item_from_followup_context");
}
}
@@ -621,6 +622,32 @@ function mergeFollowupFilters(
reasons.push("as_of_date_from_followup_context");
}
}
if (
!sameDateRequested &&
(intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date") &&
!hasExplicitPeriodLiteral(userMessage) &&
!hasExplicitCurrentDateHint(userMessage)
) {
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
const currentAsOfDate = toNonEmptyString(merged.as_of_date);
const todayIso = new Date().toISOString().slice(0, 10);
const currentLooksDefaultedToToday = currentAsOfDate === todayIso;
if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) {
merged.as_of_date = inheritedAsOfDate;
reasons.push("as_of_date_from_followup_context");
}
}
if (
!sameDateRequested &&
(intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date") &&
hasOpenItemsHint(userMessage)
) {
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
if (inheritedAsOfDate && merged.as_of_date !== inheritedAsOfDate) {
merged.as_of_date = inheritedAsOfDate;
reasons.push("as_of_date_from_open_items_followup_context");
}
}
if (intent === "inventory_aging_by_purchase_date") {
const explicitItemMention = /(?:^|[\s,.;:!?()\-\u2014])(?:товар(?:у|а|ом)?|позици(?:и|я|ю)|item|row|line)(?=$|[\s,.;:!?()\-\u2014])/iu.test(
String(userMessage ?? "")
@@ -711,7 +738,7 @@ function mergeFollowupFilters(
}
}
if (!currentHasPeriod && previousHasPeriod && hasFollowupSignal && !(asOfPrimaryIntent && hasExplicitCurrentDateInMessage)) {
if (!currentHasPeriod && previousHasPeriod && hasFollowupSignal && !hasExplicitPeriodInMessage) {
if (previousPeriodFrom) {
merged.period_from = previousPeriodFrom;
}
@@ -16,7 +16,7 @@ const PARTY_ANCHOR_STOPWORDS = new Set([
]);
export interface AnchorResolutionDebug {
anchor_type: "account" | "counterparty" | "contract" | "document_ref" | "unknown" | null;
anchor_type: "account" | "counterparty" | "contract" | "document_ref" | "item" | "warehouse" | "unknown" | null;
anchor_value_raw: string | null;
anchor_value_resolved: string | null;
resolver_confidence: "high" | "medium" | "low" | null;
@@ -28,6 +28,8 @@ export interface ResolveStageRow {
account_dt: string | null;
account_kt: string | null;
analytics: string[];
item?: string | null;
warehouse?: string | null;
}
function transliterateCyrillicToLatin(value: string): string {
@@ -122,13 +124,39 @@ function matchesAnchorText(searchable: string, anchor: string): boolean {
}
return searchableNormalized.includes(direct) || searchableLatin.includes(transliterateCyrillicToLatin(direct));
}
return tokens.every((token) => {
const fullMatch = tokens.every((token) => {
const variants = anchorTokenVariants(token);
return variants.some((variant) => {
const tokenLatin = transliterateCyrillicToLatin(variant);
return searchableNormalized.includes(variant) || searchableLatin.includes(tokenLatin);
});
});
if (fullMatch) {
return true;
}
// Sale-trace item labels and warehouse labels can differ by punctuation or
// compact suffixes in the materialized row text. For those anchors, allow a
// narrow token-overlap fallback so the exact selected object still resolves
// against live rows instead of dropping into an empty match.
const overlapCount = tokens.reduce((count, token) => {
const variants = anchorTokenVariants(token);
return (
count +
(variants.some((variant) => {
const tokenLatin = transliterateCyrillicToLatin(variant);
return searchableNormalized.includes(variant) || searchableLatin.includes(tokenLatin);
})
? 1
: 0)
);
}, 0);
const numericTokens = tokens.filter((token) => /\d/.test(token));
const numericOverlap = numericTokens.every((token) => {
const tokenLatin = transliterateCyrillicToLatin(token);
return searchableNormalized.includes(token) || searchableLatin.includes(tokenLatin);
});
return numericOverlap && overlapCount >= Math.max(2, Math.ceil(tokens.length * 0.75));
}
function uniqueStrings(values: string[]): string[] {
@@ -145,6 +173,8 @@ export function resolvePrimaryAnchor(intent: AddressIntent, filters: AddressFilt
const account = typeof filters.account === "string" ? filters.account.trim() : "";
const counterparty = typeof filters.counterparty === "string" ? filters.counterparty.trim() : "";
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 documentRef = typeof filters.document_ref === "string" ? filters.document_ref.trim() : "";
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
@@ -203,6 +233,33 @@ export function resolvePrimaryAnchor(intent: AddressIntent, filters: AddressFilt
};
}
if (
(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") &&
item
) {
return {
anchor_type: "item",
anchor_value_raw: item,
anchor_value_resolved: item,
resolver_confidence: "medium",
ambiguity_count: 0
};
}
if (warehouse) {
return {
anchor_type: "warehouse",
anchor_value_raw: warehouse,
anchor_value_resolved: warehouse,
resolver_confidence: "medium",
ambiguity_count: 0
};
}
if (documentRef) {
return {
anchor_type: "document_ref",
@@ -226,16 +283,24 @@ export function refineAnchorFromRows(anchor: AnchorResolutionDebug, rows: Resolv
if (rows.length === 0) {
return anchor;
}
if (anchor.anchor_type !== "counterparty" && anchor.anchor_type !== "contract") {
if (
anchor.anchor_type !== "counterparty" &&
anchor.anchor_type !== "contract" &&
anchor.anchor_type !== "item" &&
anchor.anchor_type !== "warehouse"
) {
return anchor;
}
const needleRaw = String(anchor.anchor_value_raw ?? "").trim();
if (!needleRaw) {
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])
: rows.flatMap((row) => row.analytics);
const candidates = uniqueStrings(
rows
.flatMap((row) => row.analytics)
searchableRows
.map((value) => value.trim())
.filter((value) => value.length >= 2 && matchesAnchorText(value, needleRaw))
);