Закрепить широкий phase12 agent replay
This commit is contained in:
parent
b52279d19e
commit
7995980215
|
|
@ -407,6 +407,17 @@ exports.ROOT_EXACT_CAPABILITY_CONTRACTS = [
|
||||||
resultShape: "open_contracts_snapshot",
|
resultShape: "open_contracts_snapshot",
|
||||||
answerObjectShape: "open_contracts_snapshot"
|
answerObjectShape: "open_contracts_snapshot"
|
||||||
}),
|
}),
|
||||||
|
rootExactCapability({
|
||||||
|
capability_id: "address_open_items_by_counterparty_or_contract",
|
||||||
|
domainId: "contracts",
|
||||||
|
intent_ids: ["open_items_by_counterparty_or_contract"],
|
||||||
|
transitions: ["T1", "T2", "T6", "T7"],
|
||||||
|
requiredAnchors: [],
|
||||||
|
optionalAnchors: ["organization", "date_scope", "account", "counterparty", "contract"],
|
||||||
|
resultShape: "open_items_by_account_counterparty_or_contract",
|
||||||
|
answerObjectShape: "open_items_snapshot",
|
||||||
|
scenarioFamilies: ["canonical", "colloquial", "account_scope", "exact_negative_answer"]
|
||||||
|
}),
|
||||||
rootExactCapability({
|
rootExactCapability({
|
||||||
capability_id: "confirmed_vat_payable_as_of_date",
|
capability_id: "confirmed_vat_payable_as_of_date",
|
||||||
domainId: "vat",
|
domainId: "vat",
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,28 @@ function createAssistantTransitionPolicy(deps) {
|
||||||
.some((normalized) => normalized &&
|
.some((normalized) => normalized &&
|
||||||
/(?:\b(?:same|this|that)\s+period\b|(?:за|на)\s+(?:этот|тот|такой\s+же|тот\s+же)\s+период|(?:Р·Р°|РЅР°)\s+(?:этот|тот|такой\s+Р¶Рµ|тот\s+Р¶Рµ)\s+период)/iu.test(normalized));
|
/(?:\b(?:same|this|that)\s+period\b|(?:за|на)\s+(?:этот|тот|такой\s+же|тот\s+же)\s+период|(?:Р·Р°|РЅР°)\s+(?:этот|тот|такой\s+Р¶Рµ|тот\s+Р¶Рµ)\s+период)/iu.test(normalized));
|
||||||
}
|
}
|
||||||
|
function hasExplicitDateScopeSignal(...values) {
|
||||||
|
return values
|
||||||
|
.map((value) => normalizeFollowupText(value))
|
||||||
|
.some((normalized) => {
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return /(?:\b(?:19|20)\d{2}\b|\b\d{1,2}[./-]\d{1,2}[./-]\d{2,4}\b|\b\d{4}[./-]\d{1,2}[./-]\d{1,2}\b|за\s+(?:период|месяц|год|квартал)|на\s+(?:дат|конец|сегодня)|январ|феврал|март|апрел|ма(?:й|я|е)|июн|июл|август|сентябр|октябр|ноябр|декабр|same\s+period|this\s+period|that\s+period)/iu.test(normalized);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function shouldClearStaleDateScopeForCounterpartyDocsRoot(targetIntent, sourceIntentHint, userMessage, alternateMessage) {
|
||||||
|
if (targetIntent !== "list_documents_by_counterparty") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (sourceIntentHint === "list_documents_by_counterparty" || sourceIntentHint === "list_documents_by_contract") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (hasSamePeriodReferenceCue(userMessage, alternateMessage) || hasExplicitDateScopeSignal(userMessage, alternateMessage)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
function hasBankOperationsPivotCue(text) {
|
function hasBankOperationsPivotCue(text) {
|
||||||
const normalized = normalizeFollowupText(text);
|
const normalized = normalizeFollowupText(text);
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
|
|
@ -1224,6 +1246,18 @@ function createAssistantTransitionPolicy(deps) {
|
||||||
if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) {
|
if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const predecomposeIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
|
||||||
|
const carryoverTargetIntentPreview = explicitIntent ?? predecomposeIntent ?? previousIntent;
|
||||||
|
if (shouldClearStaleDateScopeForCounterpartyDocsRoot(carryoverTargetIntentPreview, sourceIntentHint, userMessage, alternateMessage)) {
|
||||||
|
delete previousFilters.as_of_date;
|
||||||
|
delete previousFilters.period_from;
|
||||||
|
delete previousFilters.period_to;
|
||||||
|
delete previousFilters.purchase_date_bridge_selected;
|
||||||
|
delete previousFilters.purchase_date_bridge_first;
|
||||||
|
delete previousFilters.purchase_date_bridge_last;
|
||||||
|
delete previousFilters.purchase_date_bridge_basis;
|
||||||
|
delete previousFilters.purchase_date_bridge_has_multiple;
|
||||||
|
}
|
||||||
const shouldAttachInventoryRootFrame = Boolean(inventoryRootFrame &&
|
const shouldAttachInventoryRootFrame = Boolean(inventoryRootFrame &&
|
||||||
(rootScopedPivot ||
|
(rootScopedPivot ||
|
||||||
deps.isInventoryRootFrameIntent(sourceIntentHint) ||
|
deps.isInventoryRootFrameIntent(sourceIntentHint) ||
|
||||||
|
|
@ -1238,7 +1272,6 @@ function createAssistantTransitionPolicy(deps) {
|
||||||
hasSelectedObjectInventorySignalPrimary ||
|
hasSelectedObjectInventorySignalPrimary ||
|
||||||
hasSelectedObjectInventorySignalAlternate));
|
hasSelectedObjectInventorySignalAlternate));
|
||||||
const previousHasPeriodWindow = Boolean(deps.toNonEmptyString(previousFilters.period_from) || deps.toNonEmptyString(previousFilters.period_to));
|
const previousHasPeriodWindow = Boolean(deps.toNonEmptyString(previousFilters.period_from) || deps.toNonEmptyString(previousFilters.period_to));
|
||||||
const predecomposeIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
|
|
||||||
const shouldRetargetVatSamePeriod = previousHasPeriodWindow &&
|
const shouldRetargetVatSamePeriod = previousHasPeriodWindow &&
|
||||||
hasSamePeriodReferenceCue(userMessage, alternateMessage) &&
|
hasSamePeriodReferenceCue(userMessage, alternateMessage) &&
|
||||||
(explicitIntent === "vat_payable_confirmed_as_of_date" ||
|
(explicitIntent === "vat_payable_confirmed_as_of_date" ||
|
||||||
|
|
|
||||||
|
|
@ -435,6 +435,17 @@ export const ROOT_EXACT_CAPABILITY_CONTRACTS: readonly AssistantCapabilityContra
|
||||||
resultShape: "open_contracts_snapshot",
|
resultShape: "open_contracts_snapshot",
|
||||||
answerObjectShape: "open_contracts_snapshot"
|
answerObjectShape: "open_contracts_snapshot"
|
||||||
}),
|
}),
|
||||||
|
rootExactCapability({
|
||||||
|
capability_id: "address_open_items_by_counterparty_or_contract",
|
||||||
|
domainId: "contracts",
|
||||||
|
intent_ids: ["open_items_by_counterparty_or_contract"],
|
||||||
|
transitions: ["T1", "T2", "T6", "T7"],
|
||||||
|
requiredAnchors: [],
|
||||||
|
optionalAnchors: ["organization", "date_scope", "account", "counterparty", "contract"],
|
||||||
|
resultShape: "open_items_by_account_counterparty_or_contract",
|
||||||
|
answerObjectShape: "open_items_snapshot",
|
||||||
|
scenarioFamilies: ["canonical", "colloquial", "account_scope", "exact_negative_answer"]
|
||||||
|
}),
|
||||||
rootExactCapability({
|
rootExactCapability({
|
||||||
capability_id: "confirmed_vat_payable_as_of_date",
|
capability_id: "confirmed_vat_payable_as_of_date",
|
||||||
domainId: "vat",
|
domainId: "vat",
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,37 @@ export function createAssistantTransitionPolicy(deps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasExplicitDateScopeSignal(...values) {
|
||||||
|
return values
|
||||||
|
.map((value) => normalizeFollowupText(value))
|
||||||
|
.some((normalized) => {
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return /(?:\b(?:19|20)\d{2}\b|\b\d{1,2}[./-]\d{1,2}[./-]\d{2,4}\b|\b\d{4}[./-]\d{1,2}[./-]\d{1,2}\b|за\s+(?:период|месяц|год|квартал)|на\s+(?:дат|конец|сегодня)|январ|феврал|март|апрел|ма(?:й|я|е)|июн|июл|август|сентябр|октябр|ноябр|декабр|same\s+period|this\s+period|that\s+period)/iu.test(
|
||||||
|
normalized
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldClearStaleDateScopeForCounterpartyDocsRoot(
|
||||||
|
targetIntent,
|
||||||
|
sourceIntentHint,
|
||||||
|
userMessage,
|
||||||
|
alternateMessage
|
||||||
|
) {
|
||||||
|
if (targetIntent !== "list_documents_by_counterparty") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (sourceIntentHint === "list_documents_by_counterparty" || sourceIntentHint === "list_documents_by_contract") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (hasSamePeriodReferenceCue(userMessage, alternateMessage) || hasExplicitDateScopeSignal(userMessage, alternateMessage)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function hasBankOperationsPivotCue(text) {
|
function hasBankOperationsPivotCue(text) {
|
||||||
const normalized = normalizeFollowupText(text);
|
const normalized = normalizeFollowupText(text);
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
|
|
@ -1663,6 +1694,25 @@ export function createAssistantTransitionPolicy(deps) {
|
||||||
if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) {
|
if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const predecomposeIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
|
||||||
|
const carryoverTargetIntentPreview = explicitIntent ?? predecomposeIntent ?? previousIntent;
|
||||||
|
if (
|
||||||
|
shouldClearStaleDateScopeForCounterpartyDocsRoot(
|
||||||
|
carryoverTargetIntentPreview,
|
||||||
|
sourceIntentHint,
|
||||||
|
userMessage,
|
||||||
|
alternateMessage
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
delete previousFilters.as_of_date;
|
||||||
|
delete previousFilters.period_from;
|
||||||
|
delete previousFilters.period_to;
|
||||||
|
delete previousFilters.purchase_date_bridge_selected;
|
||||||
|
delete previousFilters.purchase_date_bridge_first;
|
||||||
|
delete previousFilters.purchase_date_bridge_last;
|
||||||
|
delete previousFilters.purchase_date_bridge_basis;
|
||||||
|
delete previousFilters.purchase_date_bridge_has_multiple;
|
||||||
|
}
|
||||||
const shouldAttachInventoryRootFrame = Boolean(
|
const shouldAttachInventoryRootFrame = Boolean(
|
||||||
inventoryRootFrame &&
|
inventoryRootFrame &&
|
||||||
(rootScopedPivot ||
|
(rootScopedPivot ||
|
||||||
|
|
@ -1681,7 +1731,6 @@ export function createAssistantTransitionPolicy(deps) {
|
||||||
const previousHasPeriodWindow = Boolean(
|
const previousHasPeriodWindow = Boolean(
|
||||||
deps.toNonEmptyString(previousFilters.period_from) || deps.toNonEmptyString(previousFilters.period_to)
|
deps.toNonEmptyString(previousFilters.period_from) || deps.toNonEmptyString(previousFilters.period_to)
|
||||||
);
|
);
|
||||||
const predecomposeIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
|
|
||||||
const shouldRetargetVatSamePeriod =
|
const shouldRetargetVatSamePeriod =
|
||||||
previousHasPeriodWindow &&
|
previousHasPeriodWindow &&
|
||||||
hasSamePeriodReferenceCue(userMessage, alternateMessage) &&
|
hasSamePeriodReferenceCue(userMessage, alternateMessage) &&
|
||||||
|
|
|
||||||
|
|
@ -4944,6 +4944,27 @@ describe("address decompose stage follow-up carryover", () => {
|
||||||
expect(result?.baseReasons).toContain("organization_from_followup_context");
|
expect(result?.baseReasons).toContain("organization_from_followup_context");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not inherit stale debt date when a new counterparty documents root starts", () => {
|
||||||
|
const result = runAddressDecomposeStage("покажи документы по свк", {
|
||||||
|
previous_intent: "receivables_confirmed_as_of_date",
|
||||||
|
previous_filters: {
|
||||||
|
organization: 'ООО "Альтернатива Плюс"',
|
||||||
|
period_from: "2017-05-01",
|
||||||
|
period_to: "2017-05-31",
|
||||||
|
as_of_date: "2017-05-31"
|
||||||
|
},
|
||||||
|
previous_anchor_type: "unknown",
|
||||||
|
previous_anchor_value: null
|
||||||
|
});
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.intent.intent).toBe("list_documents_by_counterparty");
|
||||||
|
expect(result?.filters.extracted_filters.counterparty).toBe("свк");
|
||||||
|
expect(result?.filters.extracted_filters.organization).toBe('ООО "Альтернатива Плюс"');
|
||||||
|
expect(result?.filters.extracted_filters.as_of_date).toBeUndefined();
|
||||||
|
expect(result?.filters.extracted_filters.period_from).toBeUndefined();
|
||||||
|
expect(result?.filters.extracted_filters.period_to).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("inherits as_of_date from previous period for same-date balance follow-up", () => {
|
it("inherits as_of_date from previous period for same-date balance follow-up", () => {
|
||||||
const result = runAddressDecomposeStage("а по счету 60.01 на ту же дату", {
|
const result = runAddressDecomposeStage("а по счету 60.01 на ту же дату", {
|
||||||
previous_intent: "list_documents_by_counterparty",
|
previous_intent: "list_documents_by_counterparty",
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,16 @@ describe("assistant runtime contract registry", () => {
|
||||||
expect(contract?.execution_adapter).toBe("AddressQueryService");
|
expect(contract?.execution_adapter).toBe("AddressQueryService");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("declares open-items account/counterparty capability as a contracted runtime route", () => {
|
||||||
|
const contract = getAssistantCapabilityContractByIntent("open_items_by_counterparty_or_contract");
|
||||||
|
|
||||||
|
expect(contract?.capability_id).toBe("address_open_items_by_counterparty_or_contract");
|
||||||
|
expect(contract?.runtime_lane).toBe("address_exact");
|
||||||
|
expect(contract?.supported_transition_classes).toEqual(["T1", "T2", "T6", "T7"]);
|
||||||
|
expect(contract?.result_shape).toBe("open_items_by_account_counterparty_or_contract");
|
||||||
|
expect(contract?.required_scenario_families).toContain("exact_negative_answer");
|
||||||
|
});
|
||||||
|
|
||||||
it("declares counterparty document and value-flow contracts used by semantic dialog authority replay", () => {
|
it("declares counterparty document and value-flow contracts used by semantic dialog authority replay", () => {
|
||||||
const documents = getAssistantCapabilityContractByIntent("list_documents_by_counterparty");
|
const documents = getAssistantCapabilityContractByIntent("list_documents_by_counterparty");
|
||||||
expect(documents?.capability_id).toBe("documents_drilldown");
|
expect(documents?.capability_id).toBe("documents_drilldown");
|
||||||
|
|
|
||||||
|
|
@ -712,6 +712,21 @@ def merge_scenario_date_scope(
|
||||||
|
|
||||||
def question_resets_temporal_scope(value: Any) -> bool:
|
def question_resets_temporal_scope(value: Any) -> bool:
|
||||||
normalized = str(value or "").casefold().replace("ё", "е")
|
normalized = str(value or "").casefold().replace("ё", "е")
|
||||||
|
same_scope_markers = (
|
||||||
|
"за тот же период",
|
||||||
|
"на тот же период",
|
||||||
|
"за этот же период",
|
||||||
|
"на этот же период",
|
||||||
|
"за этот период",
|
||||||
|
"на этот период",
|
||||||
|
"на ту же дату",
|
||||||
|
"на эту же дату",
|
||||||
|
"на эту дату",
|
||||||
|
"same period",
|
||||||
|
"same date",
|
||||||
|
)
|
||||||
|
if any(marker in normalized for marker in same_scope_markers):
|
||||||
|
return False
|
||||||
markers = (
|
markers = (
|
||||||
"за все доступное время",
|
"за все доступное время",
|
||||||
"за все время",
|
"за все время",
|
||||||
|
|
@ -725,7 +740,14 @@ def question_resets_temporal_scope(value: Any) -> bool:
|
||||||
"не тащи период",
|
"не тащи период",
|
||||||
"не тащи старый период",
|
"не тащи старый период",
|
||||||
)
|
)
|
||||||
return any(marker in normalized for marker in markers)
|
if any(marker in normalized for marker in markers):
|
||||||
|
return True
|
||||||
|
has_document_signal = bool(re.search(r"\b(?:док(?:и|умент\w*)?|docs?|documents?)\b", normalized, flags=re.IGNORECASE))
|
||||||
|
has_anchor_signal = bool(re.search(r"(?:^|\s)по\s+[\w\"'«».-]{2,}", normalized, flags=re.IGNORECASE))
|
||||||
|
has_root_action_signal = bool(
|
||||||
|
re.search(r"\b(?:покажи|выведи|найди|дай|посмотри|show|list|find)\b", normalized, flags=re.IGNORECASE)
|
||||||
|
)
|
||||||
|
return has_document_signal and has_anchor_signal and has_root_action_signal
|
||||||
|
|
||||||
|
|
||||||
def repair_text_mojibake(value: str) -> str:
|
def repair_text_mojibake(value: str) -> str:
|
||||||
|
|
@ -809,6 +831,21 @@ def normalize_validation_filters(raw_filters: Any) -> dict[str, str]:
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_entity_filter_value_for_comparison(value: Any) -> str:
|
||||||
|
text = repair_text_mojibake(str(value or "")).casefold().replace("ё", "е")
|
||||||
|
text = re.sub(r"[\"'«»“”„]", "", text)
|
||||||
|
text = re.sub(r"[\s\-–—]+", " ", text)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def validation_filter_values_match(filter_key: str, actual_value: str, expected_value: str) -> bool:
|
||||||
|
if actual_value == expected_value:
|
||||||
|
return True
|
||||||
|
if filter_key in {"organization", "counterparty", "contract", "warehouse"}:
|
||||||
|
return normalize_entity_filter_value_for_comparison(actual_value) == normalize_entity_filter_value_for_comparison(expected_value)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def normalize_invariant_severity(raw_mapping: Any) -> dict[str, str]:
|
def normalize_invariant_severity(raw_mapping: Any) -> dict[str, str]:
|
||||||
if not isinstance(raw_mapping, dict):
|
if not isinstance(raw_mapping, dict):
|
||||||
return {}
|
return {}
|
||||||
|
|
@ -2461,15 +2498,34 @@ def is_validated_confirmed_runtime_answer(
|
||||||
and business_review.get("answer_layering_ok") is True
|
and business_review.get("answer_layering_ok") is True
|
||||||
and business_review.get("technical_garbage_present") is False
|
and business_review.get("technical_garbage_present") is False
|
||||||
)
|
)
|
||||||
if str(state.get("reply_type") or "").strip() not in {"factual", "factual_with_explanation", "empty_but_valid"}:
|
reply_type = str(state.get("reply_type") or "").strip()
|
||||||
|
if reply_type not in {"factual", "factual_with_explanation", "partial_coverage", "empty_but_valid"}:
|
||||||
return False
|
return False
|
||||||
if str(state.get("fallback_type") or "").strip() not in {"", "none"}:
|
if str(state.get("fallback_type") or "").strip() not in {"", "none"}:
|
||||||
return False
|
return False
|
||||||
if str(state.get("mcp_call_status") or "").strip() != "matched_non_empty":
|
|
||||||
return False
|
|
||||||
response_type = str(state.get("response_type") or "").strip()
|
response_type = str(state.get("response_type") or "").strip()
|
||||||
if response_type not in {"FACTUAL_LIST", "FACTUAL_SUMMARY"}:
|
if response_type not in {"FACTUAL_LIST", "FACTUAL_SUMMARY"}:
|
||||||
return False
|
return False
|
||||||
|
mcp_call_status = str(state.get("mcp_call_status") or "").strip()
|
||||||
|
selected_recipe = str(state.get("selected_recipe") or "").strip()
|
||||||
|
truth_gate_contract_status = str(state.get("truth_gate_contract_status") or "").strip()
|
||||||
|
exact_negative_open_items = (
|
||||||
|
mcp_call_status == "no_raw_rows"
|
||||||
|
and state.get("balance_confirmed") is False
|
||||||
|
and selected_recipe == "address_open_items_by_party_or_contract_v1"
|
||||||
|
and truth_gate_contract_status == "full_confirmed"
|
||||||
|
)
|
||||||
|
if exact_negative_open_items:
|
||||||
|
return (
|
||||||
|
business_review.get("business_usefulness_ok") is True
|
||||||
|
and business_review.get("direct_answer_first_ok") is True
|
||||||
|
and business_review.get("answer_layering_ok") is True
|
||||||
|
and business_review.get("technical_garbage_present") is False
|
||||||
|
)
|
||||||
|
if reply_type not in {"factual", "factual_with_explanation", "empty_but_valid"}:
|
||||||
|
return False
|
||||||
|
if mcp_call_status != "matched_non_empty":
|
||||||
|
return False
|
||||||
result_mode = str(state.get("result_mode") or "").strip()
|
result_mode = str(state.get("result_mode") or "").strip()
|
||||||
truth_mode = str(state.get("truth_mode") or "").strip()
|
truth_mode = str(state.get("truth_mode") or "").strip()
|
||||||
answer_shape = str(state.get("answer_shape") or "").strip()
|
answer_shape = str(state.get("answer_shape") or "").strip()
|
||||||
|
|
@ -2590,6 +2646,42 @@ def is_validated_missing_axis_clarification_answer(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_validated_clean_meta_chat_answer(
|
||||||
|
state: dict[str, Any],
|
||||||
|
execution_status: str,
|
||||||
|
business_review: dict[str, Any],
|
||||||
|
violations: list[str],
|
||||||
|
) -> bool:
|
||||||
|
if execution_status != "needs_exact_capability":
|
||||||
|
return False
|
||||||
|
if violations:
|
||||||
|
return False
|
||||||
|
semantic_tags = set(normalize_string_list(state.get("semantic_tags")))
|
||||||
|
allowed_tags = {
|
||||||
|
"meta_smalltalk",
|
||||||
|
"company_selected",
|
||||||
|
"organization_authority",
|
||||||
|
"meta_capability",
|
||||||
|
"human_answer_quality",
|
||||||
|
"meta_historical_capability",
|
||||||
|
"capability_over_followup",
|
||||||
|
}
|
||||||
|
if not semantic_tags.intersection(allowed_tags):
|
||||||
|
return False
|
||||||
|
if str(state.get("detected_mode") or "").strip() not in {"chat", "unsupported", ""}:
|
||||||
|
return False
|
||||||
|
if str(state.get("reply_type") or "").strip() not in {"factual", "factual_with_explanation", "partial_coverage"}:
|
||||||
|
return False
|
||||||
|
if not str(state.get("assistant_text") or "").strip():
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
business_review.get("business_usefulness_ok") is True
|
||||||
|
and business_review.get("direct_answer_first_ok") is True
|
||||||
|
and business_review.get("answer_layering_ok") is True
|
||||||
|
and business_review.get("technical_garbage_present") is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _business_review_is_clean(step_state: dict[str, Any]) -> bool:
|
def _business_review_is_clean(step_state: dict[str, Any]) -> bool:
|
||||||
business_review = step_state.get("business_first_review")
|
business_review = step_state.get("business_first_review")
|
||||||
if not isinstance(business_review, dict):
|
if not isinstance(business_review, dict):
|
||||||
|
|
@ -2710,12 +2802,12 @@ def business_expected_result_mode_matches(expected_result_mode: str, step_state:
|
||||||
def acceptance_status_from_execution(execution_status: str, hard_fail: bool, semantic_validated: bool = False) -> str:
|
def acceptance_status_from_execution(execution_status: str, hard_fail: bool, semantic_validated: bool = False) -> str:
|
||||||
if execution_status == "blocked":
|
if execution_status == "blocked":
|
||||||
return "blocked"
|
return "blocked"
|
||||||
if execution_status == "needs_exact_capability":
|
|
||||||
return "needs_exact_capability"
|
|
||||||
if hard_fail:
|
if hard_fail:
|
||||||
return "rejected"
|
return "rejected"
|
||||||
if semantic_validated:
|
if semantic_validated:
|
||||||
return "validated"
|
return "validated"
|
||||||
|
if execution_status == "needs_exact_capability":
|
||||||
|
return "needs_exact_capability"
|
||||||
if execution_status == "exact":
|
if execution_status == "exact":
|
||||||
return "validated"
|
return "validated"
|
||||||
return "rejected"
|
return "rejected"
|
||||||
|
|
@ -2784,7 +2876,7 @@ def validate_step_contract(step_state: dict[str, Any]) -> dict[str, Any]:
|
||||||
if not actual_value:
|
if not actual_value:
|
||||||
violated_invariants.append("missing_required_filter")
|
violated_invariants.append("missing_required_filter")
|
||||||
continue
|
continue
|
||||||
if actual_value != expected_value:
|
if not validation_filter_values_match(filter_key, actual_value, expected_value):
|
||||||
if filter_key == "as_of_date":
|
if filter_key == "as_of_date":
|
||||||
violated_invariants.append("wrong_as_of_date")
|
violated_invariants.append("wrong_as_of_date")
|
||||||
elif filter_key == "period_from":
|
elif filter_key == "period_from":
|
||||||
|
|
@ -2876,6 +2968,12 @@ def validate_step_contract(step_state: dict[str, Any]) -> dict[str, Any]:
|
||||||
business_review,
|
business_review,
|
||||||
unique_violations,
|
unique_violations,
|
||||||
)
|
)
|
||||||
|
clean_meta_chat_validated = is_validated_clean_meta_chat_answer(
|
||||||
|
state,
|
||||||
|
execution_status,
|
||||||
|
business_review,
|
||||||
|
unique_violations,
|
||||||
|
)
|
||||||
state["violated_invariants"] = unique_violations
|
state["violated_invariants"] = unique_violations
|
||||||
state["warnings"] = list(dict.fromkeys(warnings))
|
state["warnings"] = list(dict.fromkeys(warnings))
|
||||||
state["hard_fail"] = hard_fail
|
state["hard_fail"] = hard_fail
|
||||||
|
|
@ -2885,6 +2983,7 @@ def validate_step_contract(step_state: dict[str, Any]) -> dict[str, Any]:
|
||||||
state["guarded_insufficiency_validated"] = guarded_insufficiency_validated
|
state["guarded_insufficiency_validated"] = guarded_insufficiency_validated
|
||||||
state["clarification_answer_validated"] = clarification_validated
|
state["clarification_answer_validated"] = clarification_validated
|
||||||
state["missing_axis_clarification_validated"] = missing_axis_clarification_validated
|
state["missing_axis_clarification_validated"] = missing_axis_clarification_validated
|
||||||
|
state["clean_meta_chat_answer_validated"] = clean_meta_chat_validated
|
||||||
state["acceptance_status"] = acceptance_status_from_execution(
|
state["acceptance_status"] = acceptance_status_from_execution(
|
||||||
execution_status,
|
execution_status,
|
||||||
hard_fail,
|
hard_fail,
|
||||||
|
|
@ -2895,6 +2994,7 @@ def validate_step_contract(step_state: dict[str, Any]) -> dict[str, Any]:
|
||||||
or guarded_insufficiency_validated
|
or guarded_insufficiency_validated
|
||||||
or clarification_validated
|
or clarification_validated
|
||||||
or missing_axis_clarification_validated
|
or missing_axis_clarification_validated
|
||||||
|
or clean_meta_chat_validated
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
state["status"] = state["acceptance_status"]
|
state["status"] = state["acceptance_status"]
|
||||||
|
|
@ -2935,8 +3035,19 @@ def build_scenario_step_state(
|
||||||
if not effective_analysis_context:
|
if not effective_analysis_context:
|
||||||
effective_analysis_context = step.get("analysis_context") if isinstance(step.get("analysis_context"), dict) else {}
|
effective_analysis_context = step.get("analysis_context") if isinstance(step.get("analysis_context"), dict) else {}
|
||||||
current_date_scope = derive_mcp_discovery_business_overview_date_scope(debug)
|
current_date_scope = derive_mcp_discovery_business_overview_date_scope(debug)
|
||||||
if current_date_scope is None:
|
current_date_scope_from_session_context = current_date_scope is None
|
||||||
|
if current_date_scope_from_session_context:
|
||||||
current_date_scope = context.get("date_scope")
|
current_date_scope = context.get("date_scope")
|
||||||
|
debug_extracted_filters = debug.get("extracted_filters") if isinstance(debug.get("extracted_filters"), dict) else {}
|
||||||
|
if current_date_scope_from_session_context and question_resets_temporal_scope(question_resolved) and not any(
|
||||||
|
normalize_iso_date(debug_extracted_filters.get(key)) for key in ("as_of_date", "period_from", "period_to")
|
||||||
|
):
|
||||||
|
current_date_scope = {
|
||||||
|
"as_of_date": None,
|
||||||
|
"period_from": None,
|
||||||
|
"period_to": None,
|
||||||
|
"source": "question_temporal_scope_reset",
|
||||||
|
}
|
||||||
runtime_contract = (
|
runtime_contract = (
|
||||||
debug.get("assistant_runtime_contract_v1")
|
debug.get("assistant_runtime_contract_v1")
|
||||||
if isinstance(debug.get("assistant_runtime_contract_v1"), dict)
|
if isinstance(debug.get("assistant_runtime_contract_v1"), dict)
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,9 @@ class DomainCaseLoopStepStateTests(unittest.TestCase):
|
||||||
def test_temporal_reset_question_skips_carried_date_scope(self) -> None:
|
def test_temporal_reset_question_skips_carried_date_scope(self) -> None:
|
||||||
self.assertTrue(dcl.question_resets_temporal_scope("show money za all time"))
|
self.assertTrue(dcl.question_resets_temporal_scope("show money za all time"))
|
||||||
self.assertTrue(dcl.question_resets_temporal_scope("сколько всего денег за все доступное время"))
|
self.assertTrue(dcl.question_resets_temporal_scope("сколько всего денег за все доступное время"))
|
||||||
|
self.assertTrue(dcl.question_resets_temporal_scope("по чепурнову покажи все доки"))
|
||||||
|
self.assertTrue(dcl.question_resets_temporal_scope("покажи документы по свк"))
|
||||||
|
self.assertFalse(dcl.question_resets_temporal_scope("а документы по этому же договору за тот же период"))
|
||||||
|
|
||||||
carried = dcl.carry_forward_analysis_context(
|
carried = dcl.carry_forward_analysis_context(
|
||||||
{
|
{
|
||||||
|
|
@ -271,6 +274,182 @@ class DomainCaseLoopStepStateTests(unittest.TestCase):
|
||||||
self.assertNotIn("as_of_date", carried)
|
self.assertNotIn("as_of_date", carried)
|
||||||
self.assertEqual(carried["organization_scope"], {"label": "ООО Альтернатива Плюс"})
|
self.assertEqual(carried["organization_scope"], {"label": "ООО Альтернатива Плюс"})
|
||||||
|
|
||||||
|
def test_temporal_reset_question_clears_stale_navigation_date_scope(self) -> None:
|
||||||
|
step_state = dcl.build_scenario_step_state(
|
||||||
|
scenario_id="docs_reset_demo",
|
||||||
|
domain="agentic_loop",
|
||||||
|
step={
|
||||||
|
"step_id": "step_01",
|
||||||
|
"title": "Docs root",
|
||||||
|
"depends_on": [],
|
||||||
|
"question_template": "по чепурнову покажи все доки",
|
||||||
|
"expected_intents": ["list_documents_by_counterparty"],
|
||||||
|
"expected_recipe": "address_documents_by_counterparty_v1",
|
||||||
|
},
|
||||||
|
step_index=1,
|
||||||
|
question_resolved="по чепурнову покажи все доки",
|
||||||
|
analysis_context={},
|
||||||
|
turn_artifact={
|
||||||
|
"assistant_message": {
|
||||||
|
"reply_type": "factual",
|
||||||
|
"text": "Контрагент: Чепурнов П.Д. Найдено документов: 1.",
|
||||||
|
"message_id": "msg-1",
|
||||||
|
"trace_id": "trace-1",
|
||||||
|
},
|
||||||
|
"technical_debug_payload": {
|
||||||
|
"detected_mode": "address_query",
|
||||||
|
"detected_intent": "list_documents_by_counterparty",
|
||||||
|
"selected_recipe": "address_documents_by_counterparty_v1",
|
||||||
|
"capability_id": "documents_drilldown",
|
||||||
|
"capability_contract_id": "documents_drilldown",
|
||||||
|
"truth_gate_contract_status": "full_confirmed",
|
||||||
|
"extracted_filters": {"counterparty": "Чепурнов", "organization": "ООО Альтернатива Плюс"},
|
||||||
|
},
|
||||||
|
"session_summary": {
|
||||||
|
"address_navigation_state": {
|
||||||
|
"session_context": {
|
||||||
|
"date_scope": {
|
||||||
|
"as_of_date": "2017-05-31",
|
||||||
|
"period_from": None,
|
||||||
|
"period_to": None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
entries=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNone(step_state["date_scope"]["as_of_date"])
|
||||||
|
self.assertEqual(step_state["date_scope"]["source"], "question_temporal_scope_reset")
|
||||||
|
|
||||||
|
def test_open_items_exact_negative_answer_validates_without_rows(self) -> None:
|
||||||
|
step_state = dcl.build_scenario_step_state(
|
||||||
|
scenario_id="open_items_negative_demo",
|
||||||
|
domain="agentic_loop",
|
||||||
|
step={
|
||||||
|
"step_id": "step_01",
|
||||||
|
"title": "Open items account 60",
|
||||||
|
"depends_on": [],
|
||||||
|
"question_template": "хвосты покажи по счету 60 на август 2022",
|
||||||
|
"expected_intents": ["open_items_by_counterparty_or_contract"],
|
||||||
|
},
|
||||||
|
step_index=1,
|
||||||
|
question_resolved="хвосты покажи по счету 60 на август 2022",
|
||||||
|
analysis_context={},
|
||||||
|
turn_artifact={
|
||||||
|
"assistant_message": {
|
||||||
|
"reply_type": "partial_coverage",
|
||||||
|
"text": "По счету 60 за период 2022-08-01..2022-08-31 открытых остатков не найдено.",
|
||||||
|
"message_id": "msg-1",
|
||||||
|
"trace_id": "trace-1",
|
||||||
|
},
|
||||||
|
"technical_debug_payload": {
|
||||||
|
"detected_mode": "address_query",
|
||||||
|
"detected_intent": "open_items_by_counterparty_or_contract",
|
||||||
|
"selected_recipe": "address_open_items_by_party_or_contract_v1",
|
||||||
|
"capability_id": "address_open_items_by_counterparty_or_contract",
|
||||||
|
"capability_contract_id": "address_open_items_by_counterparty_or_contract",
|
||||||
|
"truth_gate_contract_status": "full_confirmed",
|
||||||
|
"response_type": "FACTUAL_SUMMARY",
|
||||||
|
"result_mode": "confirmed_balance",
|
||||||
|
"truth_mode": "limited",
|
||||||
|
"answer_shape": "limited_with_reason",
|
||||||
|
"mcp_call_status": "no_raw_rows",
|
||||||
|
"balance_confirmed": False,
|
||||||
|
"extracted_filters": {
|
||||||
|
"account": "60",
|
||||||
|
"period_from": "2022-08-01",
|
||||||
|
"period_to": "2022-08-31",
|
||||||
|
"as_of_date": "2022-08-31",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"session_summary": {},
|
||||||
|
},
|
||||||
|
entries=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(step_state["status"], "validated")
|
||||||
|
self.assertTrue(step_state["runtime_factual_answer_validated"])
|
||||||
|
|
||||||
|
def test_required_organization_filter_accepts_quoted_legal_name(self) -> None:
|
||||||
|
step_state = dcl.build_scenario_step_state(
|
||||||
|
scenario_id="quoted_org_filter_demo",
|
||||||
|
domain="agentic_loop",
|
||||||
|
step={
|
||||||
|
"step_id": "step_01",
|
||||||
|
"title": "Inventory root",
|
||||||
|
"depends_on": [],
|
||||||
|
"question_template": "что там на складе по остаткам",
|
||||||
|
"expected_intents": ["inventory_on_hand_as_of_date"],
|
||||||
|
"required_filters": {"organization": "ООО Альтернатива Плюс"},
|
||||||
|
},
|
||||||
|
step_index=1,
|
||||||
|
question_resolved="что там на складе по остаткам",
|
||||||
|
analysis_context={},
|
||||||
|
turn_artifact={
|
||||||
|
"assistant_message": {
|
||||||
|
"reply_type": "factual",
|
||||||
|
"text": "На складе подтверждено 11 позиций.",
|
||||||
|
"message_id": "msg-1",
|
||||||
|
"trace_id": "trace-1",
|
||||||
|
},
|
||||||
|
"technical_debug_payload": {
|
||||||
|
"detected_mode": "address_query",
|
||||||
|
"detected_intent": "inventory_on_hand_as_of_date",
|
||||||
|
"selected_recipe": "address_inventory_on_hand_as_of_date_v1",
|
||||||
|
"capability_id": "confirmed_inventory_on_hand_as_of_date",
|
||||||
|
"capability_contract_id": "confirmed_inventory_on_hand_as_of_date",
|
||||||
|
"truth_gate_contract_status": "full_confirmed",
|
||||||
|
"response_type": "FACTUAL_LIST",
|
||||||
|
"truth_mode": "confirmed",
|
||||||
|
"answer_shape": "confirmed_factual",
|
||||||
|
"mcp_call_status": "matched_non_empty",
|
||||||
|
"balance_confirmed": True,
|
||||||
|
"extracted_filters": {"organization": 'ООО "Альтернатива Плюс"', "as_of_date": "2026-06-02"},
|
||||||
|
},
|
||||||
|
"session_summary": {},
|
||||||
|
},
|
||||||
|
entries=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(step_state["status"], "validated")
|
||||||
|
self.assertNotIn("missing_required_filter", step_state["violated_invariants"])
|
||||||
|
|
||||||
|
def test_clean_meta_chat_answer_validates_without_exact_capability(self) -> None:
|
||||||
|
step_state = dcl.build_scenario_step_state(
|
||||||
|
scenario_id="meta_chat_demo",
|
||||||
|
domain="agentic_loop",
|
||||||
|
step={
|
||||||
|
"step_id": "step_01",
|
||||||
|
"title": "Capability meta",
|
||||||
|
"depends_on": [],
|
||||||
|
"question_template": "расскажи что можешь интересного",
|
||||||
|
"semantic_tags": ["meta_capability", "human_answer_quality"],
|
||||||
|
"required_answer_patterns_all": ["(?i)1с", "(?i)могу"],
|
||||||
|
},
|
||||||
|
step_index=1,
|
||||||
|
question_resolved="расскажи что можешь интересного",
|
||||||
|
analysis_context={},
|
||||||
|
turn_artifact={
|
||||||
|
"assistant_message": {
|
||||||
|
"reply_type": "factual_with_explanation",
|
||||||
|
"text": "Могу помочь с быстрым анализом данных 1С: долги, остатки, НДС и документы.",
|
||||||
|
"message_id": "msg-1",
|
||||||
|
"trace_id": "trace-1",
|
||||||
|
},
|
||||||
|
"technical_debug_payload": {
|
||||||
|
"detected_mode": "chat",
|
||||||
|
"detected_intent": None,
|
||||||
|
},
|
||||||
|
"session_summary": {},
|
||||||
|
},
|
||||||
|
entries=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(step_state["status"], "validated")
|
||||||
|
self.assertTrue(step_state["clean_meta_chat_answer_validated"])
|
||||||
|
|
||||||
def test_merge_scenario_date_scope_keeps_current_scope_over_stale_previous(self) -> None:
|
def test_merge_scenario_date_scope_keeps_current_scope_over_stale_previous(self) -> None:
|
||||||
merged = dcl.merge_scenario_date_scope(
|
merged = dcl.merge_scenario_date_scope(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue