Compare commits

...

3 Commits

13 changed files with 1241 additions and 4 deletions

View File

@ -373,9 +373,156 @@ This block is complete only when:
- truthful limited answers do not look like stale replay; - truthful limited answers do not look like stale replay;
- human answer quality becomes a structural acceptance dimension, not a soft preference. - human answer quality becomes a structural acceptance dimension, not a soft preference.
## Big Block 5. MCP Semantic Data Agent Instead Of Route Hardcoding
### Goal
Reduce the need to hardcode every new business question as a separate route by introducing a guarded semantic data-discovery layer over 1C/MCP.
This block does not mean giving Qwen3 unrestricted authority to invent arbitrary 1C queries.
It means letting the model help build and revise a data-search plan while deterministic runtime contracts still own:
- allowed MCP primitives;
- schema/catalog boundaries;
- execution budgets;
- evidence sufficiency;
- final answer truthfulness.
### Architectural Rule
The assistant may explore 1C data through MCP only through reviewed data primitives and evidence gates.
The model can propose:
- which business object to look for;
- which metric or evidence axis is needed;
- which period, organization, counterparty, contract, account, register, or document family should constrain the search;
- whether the first query result is sufficient or requires a follow-up probe.
The runtime must decide:
- whether the proposed search is allowed;
- which concrete MCP primitive or query template can execute it;
- whether returned evidence proves the answer, only supports an inference, or is insufficient;
- how the answer should describe confirmed facts, inferred facts, and unknowns.
### Required Shift
The route layer should stop being the only way to reach live 1C data.
Today, the common pattern is:
- wording signal;
- fixed intent;
- fixed route/capability;
- fixed query/reply branch.
The target pattern is:
- current-turn meaning authority;
- semantic data need;
- guarded MCP discovery plan;
- evidence object;
- answer contract.
Exact routes remain valuable for hot, high-confidence contours.
But new or long-tail business questions should be able to enter a controlled discovery lane instead of immediately becoming:
- unsupported;
- stale carryover;
- or another hand-coded route request.
### MCP Primitive Families
The discovery lane should expose a small set of broad, reviewed primitives rather than many free-form model tools:
- `search_business_entity`
- `inspect_1c_metadata`
- `resolve_entity_reference`
- `query_movements`
- `query_documents`
- `aggregate_by_axis`
- `drilldown_related_objects`
- `probe_coverage`
- `explain_evidence_basis`
These are not final API names.
They describe the architectural shape: the model plans at business level, while runtime adapters execute controlled 1C/MCP operations.
### Required Catalog Brain
The assistant needs a machine-readable 1C schema/catalog memory before this can be safe:
- available catalogs, documents, registers, and accounting axes;
- known links between counterparties, contracts, documents, accounts, payments, shipments, and balances;
- safe query templates and field mappings;
- known MCP limitations and fallback probes;
- examples of proven query recipes from accepted semantic runs.
Without this catalog brain, a model-led MCP agent will guess.
Guessing is not acceptable for accounting answers.
### Truth And Evidence Requirements
Every discovery result must emit an evidence object before answer composition:
- `confirmed_facts`
- `inferred_facts`
- `unknown_facts`
- `source_rows_summary`
- `coverage_status`
- `query_plan`
- `query_limitations`
- `confidence_reason`
- `recommended_next_probe`
The final answer may not present an inference as a confirmed 1C fact.
If the exact fact is unavailable but a useful inference is possible from 1C activity evidence, the answer must say that clearly.
### Stack Mapping
Existing seams that already point in this direction:
- `AssistantDataLayer`
- `buildLiveMcpCallPlan`
- `buildSemanticRetrievalProfile`
- `addressMcpClient.ts`
- `AddressQueryService`
- truth/coverage/evidence contracts
Primary new owner candidates:
- `assistantSemanticDataAgentPolicy.ts`
- `assistantMcpDiscoveryPlanner.ts`
- `assistantMcpEvidenceGate.ts`
- `assistantMcpCatalogIndex.ts`
The naming can change, but the ownership split should not:
- planner proposes a business-level data plan;
- catalog constrains what can be searched;
- executor runs allowed MCP primitives;
- evidence gate decides what can be said;
- answer layer explains the result in human business terms.
### Done Criteria
This block is complete only when:
- at least one long-tail 1C business question can be answered through discovery without adding a one-off route branch;
- the discovery lane produces machine-readable query/evidence artifacts;
- failed discovery degrades to a useful "what I checked / what is still unknown" answer, not a generic unsupported fallback;
- exact hot routes and semantic discovery can coexist without route collisions;
- semantic replay can prove that the model does not leak internal query mechanics or hallucinate unconfirmed facts.
## Concrete Stack Plan ## Concrete Stack Plan
This problem should be addressed in the current stack through four large architecture blocks, not through many micro-passes. This problem should be addressed in the current stack through five large architecture blocks, not through many micro-passes.
### Stack Block A. Turn Meaning Layer ### Stack Block A. Turn Meaning Layer
@ -441,6 +588,25 @@ Required result:
- top-block answer correctness becomes part of acceptance; - top-block answer correctness becomes part of acceptance;
- "route technically matched" no longer overrules semantic mismatch. - "route technically matched" no longer overrules semantic mismatch.
### Stack Block E. MCP Semantic Data Discovery Layer
Add a guarded discovery lane for business questions that are understood but not yet covered by an exact route.
Primary files and owner seams:
- [addressMcpClient.ts](/x:/1C/NDC_1C/llm_normalizer/backend/src/services/addressMcpClient.ts:1)
- [addressQueryService.ts](/x:/1C/NDC_1C/llm_normalizer/backend/src/services/addressQueryService.ts:1)
- future `assistantMcpCatalogIndex.ts`
- future `assistantMcpDiscoveryPlanner.ts`
- future `assistantMcpEvidenceGate.ts`
Required result:
- Qwen3 may help plan MCP exploration, but it cannot directly define truth;
- runtime exposes guarded MCP primitives instead of arbitrary model-generated 1C access;
- every discovery answer is backed by an explicit evidence object;
- long-tail understood business questions become recoverable without route-per-question hardcoding.
## Required Acceptance Invariants ## Required Acceptance Invariants
The architecture should not be considered corrected until the following invariants are green: The architecture should not be considered corrected until the following invariants are green:
@ -453,6 +619,10 @@ The architecture should not be considered corrected until the following invarian
6. `short_followup_retains_dialog_stem_without_glitch_replay` 6. `short_followup_retains_dialog_stem_without_glitch_replay`
7. `answer_top_block_matches_current_user_intent` 7. `answer_top_block_matches_current_user_intent`
8. `meta_interrupt_does_not_corrupt_business_thread` 8. `meta_interrupt_does_not_corrupt_business_thread`
9. `understood_long_tail_question_enters_guarded_mcp_discovery`
10. `mcp_discovery_answer_separates_confirmed_inferred_and_unknown_facts`
11. `model_planned_mcp_probe_cannot_bypass_runtime_evidence_gate`
12. `failed_discovery_reports_checked_sources_without_hallucinated_fact`
## Progress Update - 2026-04-20 ## Progress Update - 2026-04-20
@ -474,6 +644,44 @@ Important semantic conclusion:
- the turn `какой оборот был свк` now routes to `customer_revenue_and_payments` and opens with direct turnover for `Группа СВК`, not with stale `Чепурнов` documents or a generic top-client ranking; - the turn `какой оборот был свк` now routes to `customer_revenue_and_payments` and opens with direct turnover for `Группа СВК`, not with stale `Чепурнов` documents or a generic top-client ranking;
- off-domain living-chat turns after a business answer are accepted when they stay live and do not replay stale business context. - off-domain living-chat turns after a business answer are accepted when they stay live and do not replay stale business context.
## Progress Update - 2026-04-20 Contract Binding Cleanup
The same phase18 replay was rerun after adding runtime contracts for the counterparty document and value-flow capabilities:
- live replay artifact: `artifacts/domain_runs/address_truth_harness_phase18_contract_binding_rerun`
- `final_status`: `accepted`
- `steps_passed`: 7/7
Contract-layer result:
- `documents_drilldown`: `transition_contract_id=T1`, `capability_binding_status=bound`, no binding violations;
- `address_customer_revenue_and_payments`: `transition_contract_id=T1`, `capability_binding_status=bound`, no binding violations.
This closes the misleading `capability_contract_missing` / `transition_contract_not_resolved` debug gap for the core phase18 path without changing the user-facing answer semantics.
## Progress Update - 2026-04-20 MCP Discovery Contract Seed
The first implementation slice of Big Block 5 added a non-runtime-disruptive contract owner for guarded MCP semantic discovery:
- `assistantMcpDiscoveryPolicy.ts`
- `assistantMcpDiscoveryPolicy.test.ts`
This seed does not yet route live user traffic into discovery.
It establishes the safety contract that future runtime wiring must obey:
- Qwen3 may propose business-level MCP exploration primitives;
- unregistered primitives are rejected by runtime policy;
- raw model claims are never answer-authoritative;
- every discovery answer must pass an evidence gate;
- evidence must separate confirmed facts, inferred facts, and unknown facts;
- probe execution cannot bypass the runtime-approved primitive plan.
Validation:
- `npm test -- assistantMcpDiscoveryPolicy.test.ts` passed 6/6;
- `npm run build` passed.
## Execution Rule ## Execution Rule
Do not implement this plan as: Do not implement this plan as:
@ -488,7 +696,8 @@ Implement it as:
- one shared current-turn meaning authority; - one shared current-turn meaning authority;
- one explicit arbitration rule between new meaning and continuity; - one explicit arbitration rule between new meaning and continuity;
- stronger family-level semantic robustness for supported contours; - stronger family-level semantic robustness for supported contours;
- answer and replay gates that prove the assistant now feels alive to a human user. - answer and replay gates that prove the assistant now feels alive to a human user;
- guarded MCP semantic discovery for understood questions that do not deserve one-off route hardcoding.
## Bottom Line ## Bottom Line
@ -498,7 +707,8 @@ It fails because it still lacks a stable architecture for:
- recognizing the meaning of the current turn; - recognizing the meaning of the current turn;
- subordinating continuity to that meaning; - subordinating continuity to that meaning;
- and reflecting that meaning in the final user-visible answer. - reflecting that meaning in the final user-visible answer;
- and discovering relevant 1C evidence through controlled MCP primitives when no exact route exists yet.
That is the next large architecture block. That is the next large architecture block.

View File

@ -63,6 +63,7 @@ Current honest status:
- replay breadth still narrower than the intended multi-domain rollout surface beyond the flagship and late-switch families - replay breadth still narrower than the intended multi-domain rollout surface beyond the flagship and late-switch families
- remaining answer-semantics pressure inside `composeStage.ts` / `answerComposer.ts` - remaining answer-semantics pressure inside `composeStage.ts` / `answerComposer.ts`
- insufficient semantic robustness on live user wording, especially short follow-up retarget, typo tolerance, and intent-faithful human answers - insufficient semantic robustness on live user wording, especially short follow-up retarget, typo tolerance, and intent-faithful human answers
- no guarded MCP semantic discovery lane yet for understood long-tail 1C questions that should not require one-off route hardcoding
Latest live proof now includes: Latest live proof now includes:
@ -77,7 +78,7 @@ Current architectural reading:
- the system is already materially past the dangerous regression breakpoint; - the system is already materially past the dangerous regression breakpoint;
- it is now safe for continued architecture hardening and controlled domain-by-domain enablement under replay gates; - it is now safe for continued architecture hardening and controlled domain-by-domain enablement under replay gates;
- it is now materially closer to pre-multidomain stability, but still not safe to declare broad low-risk multi-domain expansion. - it is now materially closer to pre-multidomain stability, but still not safe to declare broad low-risk multi-domain expansion.
- the practical next target is now `90%+ pre-multidomain readiness`, and the remaining gap should be treated as four large architecture iterations rather than as cosmetic cleanup. - the practical next target is now `90%+ pre-multidomain readiness`, and the remaining gap should be treated as five large architecture iterations rather than as cosmetic cleanup.
- from this point onward, readiness must be judged not only by route truth and replay pass rate, but also by whether a new human user would feel that the assistant understands the intent and responds meaningfully in live wording. - from this point onward, readiness must be judged not only by route truth and replay pass rate, but also by whether a new human user would feel that the assistant understands the intent and responds meaningfully in live wording.
For the detailed audit, current percentages, and remaining debt, read: For the detailed audit, current percentages, and remaining debt, read:
@ -151,3 +152,4 @@ The biggest remaining blockers are:
- central intent pressure in `resolveAddressIntent()`; - central intent pressure in `resolveAddressIntent()`;
- remaining answer-semantics pressure in `composeStage.ts` and `answerComposer.ts`. - remaining answer-semantics pressure in `composeStage.ts` and `answerComposer.ts`.
- semantic robustness gaps where already-supported questions can still look broken to a human user because of typo sensitivity, short follow-up retarget loss, or human-answer mismatch. - semantic robustness gaps where already-supported questions can still look broken to a human user because of typo sensitivity, short follow-up retarget loss, or human-answer mismatch.
- missing MCP semantic data-discovery layer where Qwen3 can help plan controlled 1C evidence search without bypassing runtime truth gates.

View File

@ -50,6 +50,18 @@
"capability_layer": "compute", "capability_layer": "compute",
"capability_route_mode": "exact" "capability_route_mode": "exact"
}, },
{
"intent": "customer_revenue_and_payments",
"capability_id": "address_customer_revenue_and_payments",
"capability_layer": "compute",
"capability_route_mode": "exact"
},
{
"intent": "supplier_payouts_profile",
"capability_id": "address_supplier_payouts_profile",
"capability_layer": "compute",
"capability_route_mode": "exact"
},
{ {
"intent": "list_documents_by_counterparty", "intent": "list_documents_by_counterparty",
"capability_id": "documents_drilldown", "capability_id": "documents_drilldown",

View File

@ -14,6 +14,8 @@ const COMPUTE_EXACT_INTENTS = new Set([
"inventory_sale_trace_for_item", "inventory_sale_trace_for_item",
"inventory_purchase_to_sale_chain", "inventory_purchase_to_sale_chain",
"inventory_aging_by_purchase_date", "inventory_aging_by_purchase_date",
"customer_revenue_and_payments",
"supplier_payouts_profile",
"open_contracts_confirmed_as_of_date", "open_contracts_confirmed_as_of_date",
"payables_confirmed_as_of_date", "payables_confirmed_as_of_date",
"receivables_confirmed_as_of_date", "receivables_confirmed_as_of_date",
@ -128,6 +130,14 @@ function resolveCapabilityEnabled(intent) {
: "vat_liability_confirmed_tax_period_route_disabled_by_flag" : "vat_liability_confirmed_tax_period_route_disabled_by_flag"
}; };
} }
if (intent === "customer_revenue_and_payments" || intent === "supplier_payouts_profile") {
return {
enabled: config_1.FEATURE_ASSISTANT_ROUTE_ADDRESS_GENERIC_V1,
reason: config_1.FEATURE_ASSISTANT_ROUTE_ADDRESS_GENERIC_V1
? "counterparty_value_flow_route_enabled"
: "counterparty_value_flow_route_disabled_by_flag"
};
}
if (intent === "inventory_on_hand_as_of_date") { if (intent === "inventory_on_hand_as_of_date") {
return { return {
enabled: config_1.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1, enabled: config_1.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1,

View File

@ -0,0 +1,297 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ASSISTANT_MCP_DISCOVERY_PRIMITIVES = exports.ASSISTANT_MCP_DISCOVERY_EVIDENCE_SCHEMA_VERSION = exports.ASSISTANT_MCP_DISCOVERY_PLAN_SCHEMA_VERSION = void 0;
exports.isAssistantMcpDiscoveryPrimitive = isAssistantMcpDiscoveryPrimitive;
exports.buildAssistantMcpDiscoveryPlan = buildAssistantMcpDiscoveryPlan;
exports.resolveAssistantMcpDiscoveryEvidence = resolveAssistantMcpDiscoveryEvidence;
exports.ASSISTANT_MCP_DISCOVERY_PLAN_SCHEMA_VERSION = "assistant_mcp_discovery_plan_v1";
exports.ASSISTANT_MCP_DISCOVERY_EVIDENCE_SCHEMA_VERSION = "assistant_mcp_discovery_evidence_v1";
exports.ASSISTANT_MCP_DISCOVERY_PRIMITIVES = [
"search_business_entity",
"inspect_1c_metadata",
"resolve_entity_reference",
"query_movements",
"query_documents",
"aggregate_by_axis",
"drilldown_related_objects",
"probe_coverage",
"explain_evidence_basis"
];
const DEFAULT_DISCOVERY_BUDGET = {
max_probe_count: 3,
max_rows_per_probe: 100
};
const MAX_PROBE_COUNT = 6;
const MAX_ROWS_PER_PROBE = 500;
const ALLOWED_PRIMITIVE_SET = new Set(exports.ASSISTANT_MCP_DISCOVERY_PRIMITIVES);
function toNonEmptyString(value) {
if (value === null || value === undefined) {
return null;
}
const text = String(value).trim();
return text.length > 0 ? text : null;
}
function toStringList(value) {
if (!Array.isArray(value)) {
return [];
}
const result = [];
for (const item of value) {
const text = toNonEmptyString(item);
if (text && !result.includes(text)) {
result.push(text);
}
}
return result;
}
function normalizeReasonCode(value) {
const normalized = value
.trim()
.replace(/[^\p{L}\p{N}_.:-]+/gu, "_")
.replace(/^_+|_+$/g, "")
.toLowerCase();
return normalized.length > 0 ? normalized.slice(0, 120) : null;
}
function pushReason(target, value) {
const normalized = normalizeReasonCode(value);
if (normalized && !target.includes(normalized)) {
target.push(normalized);
}
}
function clampInteger(value, fallback, min, max) {
if (!Number.isFinite(value)) {
return fallback;
}
return Math.min(max, Math.max(min, Math.trunc(Number(value))));
}
function isAllowedPrimitive(value) {
return ALLOWED_PRIMITIVE_SET.has(value);
}
function normalizeTurnMeaning(value) {
if (!value) {
return null;
}
const result = {};
const domain = toNonEmptyString(value.asked_domain_family);
const action = toNonEmptyString(value.asked_action_family);
const organization = toNonEmptyString(value.explicit_organization_scope);
const dateScope = toNonEmptyString(value.explicit_date_scope);
const unsupported = toNonEmptyString(value.unsupported_but_understood_family);
const entities = toStringList(value.explicit_entity_candidates);
if (domain) {
result.asked_domain_family = domain;
}
if (action) {
result.asked_action_family = action;
}
if (entities.length > 0) {
result.explicit_entity_candidates = entities;
}
if (organization) {
result.explicit_organization_scope = organization;
}
if (dateScope) {
result.explicit_date_scope = dateScope;
}
if (Number.isFinite(value.meaning_confidence)) {
result.meaning_confidence = Math.max(0, Math.min(1, Number(value.meaning_confidence)));
}
if (unsupported) {
result.unsupported_but_understood_family = unsupported;
}
if (value.stale_replay_forbidden !== null && value.stale_replay_forbidden !== undefined) {
result.stale_replay_forbidden = Boolean(value.stale_replay_forbidden);
}
return Object.keys(result).length > 0 ? result : null;
}
function hasGroundingAxis(input) {
if (input.requiredAxes.length > 0) {
return true;
}
const meaning = input.turnMeaning;
return Boolean(meaning?.asked_domain_family ||
meaning?.asked_action_family ||
meaning?.explicit_organization_scope ||
meaning?.explicit_date_scope ||
(meaning?.explicit_entity_candidates?.length ?? 0) > 0);
}
function isAssistantMcpDiscoveryPrimitive(value) {
return isAllowedPrimitive(value);
}
function buildAssistantMcpDiscoveryPlan(input) {
const semanticDataNeed = toNonEmptyString(input.semanticDataNeed);
const turnMeaning = normalizeTurnMeaning(input.turnMeaning);
const requiredAxes = toStringList(input.requiredAxes);
const proposed = toStringList(input.proposedPrimitives);
const reasonCodes = [];
const allowedPrimitives = [];
const rejectedPrimitives = [];
for (const primitive of proposed) {
if (isAllowedPrimitive(primitive)) {
if (!allowedPrimitives.includes(primitive)) {
allowedPrimitives.push(primitive);
}
}
else {
rejectedPrimitives.push(primitive);
}
}
if (rejectedPrimitives.length > 0) {
pushReason(reasonCodes, "model_proposed_unregistered_mcp_primitive");
}
if (!semanticDataNeed) {
pushReason(reasonCodes, "semantic_data_need_missing");
}
if (!turnMeaning) {
pushReason(reasonCodes, "turn_meaning_ref_missing");
}
if (!hasGroundingAxis({ turnMeaning, requiredAxes })) {
pushReason(reasonCodes, "grounding_axis_missing");
}
if (allowedPrimitives.length === 0 && proposed.length > 0) {
pushReason(reasonCodes, "no_allowed_mcp_primitives_after_runtime_filter");
}
if (allowedPrimitives.length === 0 && proposed.length === 0) {
pushReason(reasonCodes, "mcp_primitives_not_proposed");
}
let planStatus = "allowed";
if (allowedPrimitives.length === 0 && proposed.length > 0) {
planStatus = "blocked";
}
else if (!semanticDataNeed || !turnMeaning || !hasGroundingAxis({ turnMeaning, requiredAxes })) {
planStatus = "needs_clarification";
}
else if (allowedPrimitives.length === 0) {
planStatus = "needs_clarification";
}
if (planStatus === "allowed") {
pushReason(reasonCodes, "guarded_mcp_discovery_plan_allowed");
}
else if (planStatus === "blocked") {
pushReason(reasonCodes, "guarded_mcp_discovery_plan_blocked");
}
else {
pushReason(reasonCodes, "guarded_mcp_discovery_plan_needs_clarification");
}
return {
schema_version: exports.ASSISTANT_MCP_DISCOVERY_PLAN_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryPolicy",
plan_status: planStatus,
semantic_data_need: semanticDataNeed,
turn_meaning_ref: turnMeaning,
allowed_primitives: allowedPrimitives,
rejected_primitives: rejectedPrimitives,
required_axes: requiredAxes,
execution_budget: {
max_probe_count: clampInteger(input.maxProbeCount, DEFAULT_DISCOVERY_BUDGET.max_probe_count, 1, MAX_PROBE_COUNT),
max_rows_per_probe: clampInteger(input.maxRowsPerProbe, DEFAULT_DISCOVERY_BUDGET.max_rows_per_probe, 1, MAX_ROWS_PER_PROBE)
},
requires_evidence_gate: true,
answer_may_use_raw_model_claims: false,
reason_codes: reasonCodes
};
}
function collectProbeLimitations(probeResults) {
const limitations = [];
for (const probe of probeResults) {
const limitation = toNonEmptyString(probe.limitation);
if (limitation && !limitations.includes(limitation)) {
limitations.push(limitation);
}
}
return limitations;
}
function probeRowsMatched(probeResults) {
return probeResults.reduce((sum, probe) => {
const rows = Number(probe.rows_matched ?? 0);
return sum + (Number.isFinite(rows) && rows > 0 ? rows : 0);
}, 0);
}
function probeRowsReceived(probeResults) {
return probeResults.reduce((sum, probe) => {
const rows = Number(probe.rows_received ?? 0);
return sum + (Number.isFinite(rows) && rows > 0 ? rows : 0);
}, 0);
}
function hasProbeBypass(plan, probeResults) {
const allowed = new Set(plan.allowed_primitives);
return probeResults.some((probe) => !allowed.has(probe.primitive_id));
}
function confidenceReasonFor(status) {
if (status === "confirmed") {
return "confirmed_facts_backed_by_allowed_mcp_probe_rows";
}
if (status === "inferred_only") {
return "only_inferred_facts_available_from_allowed_mcp_probe_rows";
}
if (status === "blocked") {
return "runtime_evidence_gate_blocked_discovery_answer";
}
return "allowed_mcp_probes_did_not_produce_sufficient_evidence";
}
function resolveAssistantMcpDiscoveryEvidence(input) {
const probeResults = Array.isArray(input.probeResults) ? input.probeResults : [];
const confirmedFacts = toStringList(input.confirmedFacts);
const inferredFacts = toStringList(input.inferredFacts);
const unknownFacts = toStringList(input.unknownFacts);
const sourceRowsSummary = toNonEmptyString(input.sourceRowsSummary);
const queryLimitations = [
...toStringList(input.queryLimitations),
...collectProbeLimitations(probeResults)
].filter((item, index, all) => all.indexOf(item) === index);
const reasonCodes = [...input.plan.reason_codes];
const rowsMatched = probeRowsMatched(probeResults);
const rowsReceived = probeRowsReceived(probeResults);
const bypassDetected = hasProbeBypass(input.plan, probeResults);
if (bypassDetected) {
pushReason(reasonCodes, "probe_result_used_primitive_outside_runtime_plan");
}
if (input.plan.plan_status !== "allowed") {
pushReason(reasonCodes, "plan_not_allowed_by_runtime");
}
if (confirmedFacts.length > 0 && rowsMatched <= 0) {
pushReason(reasonCodes, "confirmed_facts_without_matched_probe_rows");
}
if (!sourceRowsSummary && rowsReceived > 0) {
pushReason(reasonCodes, "source_rows_summary_missing");
}
let evidenceStatus = "insufficient";
let coverageStatus = "blocked";
let answerPermission = "checked_sources_only";
if (bypassDetected || input.plan.plan_status !== "allowed") {
evidenceStatus = "blocked";
coverageStatus = "blocked";
answerPermission = "checked_sources_only";
}
else if (confirmedFacts.length > 0 && rowsMatched > 0 && sourceRowsSummary) {
evidenceStatus = "confirmed";
coverageStatus = "full";
answerPermission = "confirmed_answer";
pushReason(reasonCodes, "confirmed_facts_with_allowed_mcp_evidence");
}
else if (inferredFacts.length > 0 && rowsReceived > 0) {
evidenceStatus = "inferred_only";
coverageStatus = "partial";
answerPermission = "bounded_inference";
pushReason(reasonCodes, "inferred_facts_require_bounded_answer");
}
else {
pushReason(reasonCodes, "mcp_discovery_evidence_insufficient");
}
return {
schema_version: exports.ASSISTANT_MCP_DISCOVERY_EVIDENCE_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryPolicy",
evidence_status: evidenceStatus,
coverage_status: coverageStatus,
answer_permission: answerPermission,
confirmed_facts: confirmedFacts,
inferred_facts: inferredFacts,
unknown_facts: unknownFacts,
source_rows_summary: sourceRowsSummary,
query_plan: input.plan,
query_limitations: queryLimitations,
confidence_reason: confidenceReasonFor(evidenceStatus),
recommended_next_probe: toNonEmptyString(input.recommendedNextProbe),
reason_codes: reasonCodes
};
}

View File

@ -324,6 +324,39 @@ exports.INVENTORY_CAPABILITY_CONTRACTS = [
}) })
]; ];
exports.ROOT_EXACT_CAPABILITY_CONTRACTS = [ exports.ROOT_EXACT_CAPABILITY_CONTRACTS = [
rootExactCapability({
capability_id: "documents_drilldown",
domainId: "counterparty_documents",
intent_ids: ["list_documents_by_counterparty", "list_documents_by_contract"],
transitions: ["T1", "T2", "T6", "T7"],
requiredAnchors: [],
optionalAnchors: ["organization", "date_scope", "counterparty", "contract", "document_type"],
resultShape: "counterparty_or_contract_document_list",
answerObjectShape: "documents_drilldown_list",
scenarioFamilies: ["canonical", "colloquial", "short_counterparty_retarget", "followup_date_carryover"]
}),
rootExactCapability({
capability_id: "address_customer_revenue_and_payments",
domainId: "counterparty_revenue",
intent_ids: ["customer_revenue_and_payments"],
transitions: ["T1", "T2", "T6", "T7"],
requiredAnchors: [],
optionalAnchors: ["organization", "date_scope", "counterparty", "contract"],
resultShape: "counterparty_revenue_payment_flow",
answerObjectShape: "counterparty_revenue_summary",
scenarioFamilies: ["canonical", "colloquial", "short_counterparty_retarget", "answer_top_block_matches_current_user_intent"]
}),
rootExactCapability({
capability_id: "address_supplier_payouts_profile",
domainId: "counterparty_payouts",
intent_ids: ["supplier_payouts_profile"],
transitions: ["T1", "T2", "T6", "T7"],
requiredAnchors: [],
optionalAnchors: ["organization", "date_scope", "counterparty", "contract"],
resultShape: "supplier_payout_payment_flow",
answerObjectShape: "supplier_payout_summary",
scenarioFamilies: ["canonical", "colloquial", "short_counterparty_retarget"]
}),
rootExactCapability({ rootExactCapability({
capability_id: "confirmed_payables_as_of_date", capability_id: "confirmed_payables_as_of_date",
domainId: "counterparty_debt", domainId: "counterparty_debt",

View File

@ -33,6 +33,8 @@ const COMPUTE_EXACT_INTENTS = new Set<AddressIntent>([
"inventory_sale_trace_for_item", "inventory_sale_trace_for_item",
"inventory_purchase_to_sale_chain", "inventory_purchase_to_sale_chain",
"inventory_aging_by_purchase_date", "inventory_aging_by_purchase_date",
"customer_revenue_and_payments",
"supplier_payouts_profile",
"open_contracts_confirmed_as_of_date", "open_contracts_confirmed_as_of_date",
"payables_confirmed_as_of_date", "payables_confirmed_as_of_date",
"receivables_confirmed_as_of_date", "receivables_confirmed_as_of_date",
@ -154,6 +156,14 @@ function resolveCapabilityEnabled(intent: AddressIntent): { enabled: boolean; re
: "vat_liability_confirmed_tax_period_route_disabled_by_flag" : "vat_liability_confirmed_tax_period_route_disabled_by_flag"
}; };
} }
if (intent === "customer_revenue_and_payments" || intent === "supplier_payouts_profile") {
return {
enabled: FEATURE_ASSISTANT_ROUTE_ADDRESS_GENERIC_V1,
reason: FEATURE_ASSISTANT_ROUTE_ADDRESS_GENERIC_V1
? "counterparty_value_flow_route_enabled"
: "counterparty_value_flow_route_disabled_by_flag"
};
}
if (intent === "inventory_on_hand_as_of_date") { if (intent === "inventory_on_hand_as_of_date") {
return { return {
enabled: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1, enabled: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1,

View File

@ -0,0 +1,411 @@
export const ASSISTANT_MCP_DISCOVERY_PLAN_SCHEMA_VERSION = "assistant_mcp_discovery_plan_v1" as const;
export const ASSISTANT_MCP_DISCOVERY_EVIDENCE_SCHEMA_VERSION = "assistant_mcp_discovery_evidence_v1" as const;
export const ASSISTANT_MCP_DISCOVERY_PRIMITIVES = [
"search_business_entity",
"inspect_1c_metadata",
"resolve_entity_reference",
"query_movements",
"query_documents",
"aggregate_by_axis",
"drilldown_related_objects",
"probe_coverage",
"explain_evidence_basis"
] as const;
export type AssistantMcpDiscoveryPrimitive = (typeof ASSISTANT_MCP_DISCOVERY_PRIMITIVES)[number];
export type AssistantMcpDiscoveryPlanStatus = "allowed" | "needs_clarification" | "blocked";
export type AssistantMcpDiscoveryCoverageStatus = "full" | "partial" | "blocked";
export type AssistantMcpDiscoveryEvidenceStatus = "confirmed" | "inferred_only" | "insufficient" | "blocked";
export type AssistantMcpDiscoveryAnswerPermission = "confirmed_answer" | "bounded_inference" | "checked_sources_only";
export interface AssistantMcpDiscoveryTurnMeaningRef {
asked_domain_family?: string | null;
asked_action_family?: string | null;
explicit_entity_candidates?: string[];
explicit_organization_scope?: string | null;
explicit_date_scope?: string | null;
meaning_confidence?: number | null;
unsupported_but_understood_family?: string | null;
stale_replay_forbidden?: boolean | null;
}
export interface AssistantMcpDiscoveryExecutionBudget {
max_probe_count: number;
max_rows_per_probe: number;
}
export interface AssistantMcpDiscoveryPlanContract {
schema_version: typeof ASSISTANT_MCP_DISCOVERY_PLAN_SCHEMA_VERSION;
policy_owner: "assistantMcpDiscoveryPolicy";
plan_status: AssistantMcpDiscoveryPlanStatus;
semantic_data_need: string | null;
turn_meaning_ref: AssistantMcpDiscoveryTurnMeaningRef | null;
allowed_primitives: AssistantMcpDiscoveryPrimitive[];
rejected_primitives: string[];
required_axes: string[];
execution_budget: AssistantMcpDiscoveryExecutionBudget;
requires_evidence_gate: true;
answer_may_use_raw_model_claims: false;
reason_codes: string[];
}
export interface BuildAssistantMcpDiscoveryPlanInput {
semanticDataNeed?: string | null;
turnMeaning?: AssistantMcpDiscoveryTurnMeaningRef | null;
proposedPrimitives?: string[] | null;
requiredAxes?: string[] | null;
maxProbeCount?: number | null;
maxRowsPerProbe?: number | null;
}
export interface AssistantMcpDiscoveryProbeResult {
primitive_id: string;
status: "ok" | "error" | "skipped";
rows_received?: number | null;
rows_matched?: number | null;
limitation?: string | null;
}
export interface ResolveAssistantMcpDiscoveryEvidenceInput {
plan: AssistantMcpDiscoveryPlanContract;
probeResults?: AssistantMcpDiscoveryProbeResult[] | null;
confirmedFacts?: string[] | null;
inferredFacts?: string[] | null;
unknownFacts?: string[] | null;
sourceRowsSummary?: string | null;
queryLimitations?: string[] | null;
recommendedNextProbe?: string | null;
}
export interface AssistantMcpDiscoveryEvidenceContract {
schema_version: typeof ASSISTANT_MCP_DISCOVERY_EVIDENCE_SCHEMA_VERSION;
policy_owner: "assistantMcpDiscoveryPolicy";
evidence_status: AssistantMcpDiscoveryEvidenceStatus;
coverage_status: AssistantMcpDiscoveryCoverageStatus;
answer_permission: AssistantMcpDiscoveryAnswerPermission;
confirmed_facts: string[];
inferred_facts: string[];
unknown_facts: string[];
source_rows_summary: string | null;
query_plan: AssistantMcpDiscoveryPlanContract;
query_limitations: string[];
confidence_reason: string;
recommended_next_probe: string | null;
reason_codes: string[];
}
const DEFAULT_DISCOVERY_BUDGET: AssistantMcpDiscoveryExecutionBudget = {
max_probe_count: 3,
max_rows_per_probe: 100
};
const MAX_PROBE_COUNT = 6;
const MAX_ROWS_PER_PROBE = 500;
const ALLOWED_PRIMITIVE_SET = new Set<string>(ASSISTANT_MCP_DISCOVERY_PRIMITIVES);
function toNonEmptyString(value: unknown): string | null {
if (value === null || value === undefined) {
return null;
}
const text = String(value).trim();
return text.length > 0 ? text : null;
}
function toStringList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const result: string[] = [];
for (const item of value) {
const text = toNonEmptyString(item);
if (text && !result.includes(text)) {
result.push(text);
}
}
return result;
}
function normalizeReasonCode(value: string): string | null {
const normalized = value
.trim()
.replace(/[^\p{L}\p{N}_.:-]+/gu, "_")
.replace(/^_+|_+$/g, "")
.toLowerCase();
return normalized.length > 0 ? normalized.slice(0, 120) : null;
}
function pushReason(target: string[], value: string): void {
const normalized = normalizeReasonCode(value);
if (normalized && !target.includes(normalized)) {
target.push(normalized);
}
}
function clampInteger(value: number | null | undefined, fallback: number, min: number, max: number): number {
if (!Number.isFinite(value)) {
return fallback;
}
return Math.min(max, Math.max(min, Math.trunc(Number(value))));
}
function isAllowedPrimitive(value: string): value is AssistantMcpDiscoveryPrimitive {
return ALLOWED_PRIMITIVE_SET.has(value);
}
function normalizeTurnMeaning(
value: AssistantMcpDiscoveryTurnMeaningRef | null | undefined
): AssistantMcpDiscoveryTurnMeaningRef | null {
if (!value) {
return null;
}
const result: AssistantMcpDiscoveryTurnMeaningRef = {};
const domain = toNonEmptyString(value.asked_domain_family);
const action = toNonEmptyString(value.asked_action_family);
const organization = toNonEmptyString(value.explicit_organization_scope);
const dateScope = toNonEmptyString(value.explicit_date_scope);
const unsupported = toNonEmptyString(value.unsupported_but_understood_family);
const entities = toStringList(value.explicit_entity_candidates);
if (domain) {
result.asked_domain_family = domain;
}
if (action) {
result.asked_action_family = action;
}
if (entities.length > 0) {
result.explicit_entity_candidates = entities;
}
if (organization) {
result.explicit_organization_scope = organization;
}
if (dateScope) {
result.explicit_date_scope = dateScope;
}
if (Number.isFinite(value.meaning_confidence)) {
result.meaning_confidence = Math.max(0, Math.min(1, Number(value.meaning_confidence)));
}
if (unsupported) {
result.unsupported_but_understood_family = unsupported;
}
if (value.stale_replay_forbidden !== null && value.stale_replay_forbidden !== undefined) {
result.stale_replay_forbidden = Boolean(value.stale_replay_forbidden);
}
return Object.keys(result).length > 0 ? result : null;
}
function hasGroundingAxis(input: {
turnMeaning: AssistantMcpDiscoveryTurnMeaningRef | null;
requiredAxes: string[];
}): boolean {
if (input.requiredAxes.length > 0) {
return true;
}
const meaning = input.turnMeaning;
return Boolean(
meaning?.asked_domain_family ||
meaning?.asked_action_family ||
meaning?.explicit_organization_scope ||
meaning?.explicit_date_scope ||
(meaning?.explicit_entity_candidates?.length ?? 0) > 0
);
}
export function isAssistantMcpDiscoveryPrimitive(value: string): value is AssistantMcpDiscoveryPrimitive {
return isAllowedPrimitive(value);
}
export function buildAssistantMcpDiscoveryPlan(
input: BuildAssistantMcpDiscoveryPlanInput
): AssistantMcpDiscoveryPlanContract {
const semanticDataNeed = toNonEmptyString(input.semanticDataNeed);
const turnMeaning = normalizeTurnMeaning(input.turnMeaning);
const requiredAxes = toStringList(input.requiredAxes);
const proposed = toStringList(input.proposedPrimitives);
const reasonCodes: string[] = [];
const allowedPrimitives: AssistantMcpDiscoveryPrimitive[] = [];
const rejectedPrimitives: string[] = [];
for (const primitive of proposed) {
if (isAllowedPrimitive(primitive)) {
if (!allowedPrimitives.includes(primitive)) {
allowedPrimitives.push(primitive);
}
} else {
rejectedPrimitives.push(primitive);
}
}
if (rejectedPrimitives.length > 0) {
pushReason(reasonCodes, "model_proposed_unregistered_mcp_primitive");
}
if (!semanticDataNeed) {
pushReason(reasonCodes, "semantic_data_need_missing");
}
if (!turnMeaning) {
pushReason(reasonCodes, "turn_meaning_ref_missing");
}
if (!hasGroundingAxis({ turnMeaning, requiredAxes })) {
pushReason(reasonCodes, "grounding_axis_missing");
}
if (allowedPrimitives.length === 0 && proposed.length > 0) {
pushReason(reasonCodes, "no_allowed_mcp_primitives_after_runtime_filter");
}
if (allowedPrimitives.length === 0 && proposed.length === 0) {
pushReason(reasonCodes, "mcp_primitives_not_proposed");
}
let planStatus: AssistantMcpDiscoveryPlanStatus = "allowed";
if (allowedPrimitives.length === 0 && proposed.length > 0) {
planStatus = "blocked";
} else if (!semanticDataNeed || !turnMeaning || !hasGroundingAxis({ turnMeaning, requiredAxes })) {
planStatus = "needs_clarification";
} else if (allowedPrimitives.length === 0) {
planStatus = "needs_clarification";
}
if (planStatus === "allowed") {
pushReason(reasonCodes, "guarded_mcp_discovery_plan_allowed");
} else if (planStatus === "blocked") {
pushReason(reasonCodes, "guarded_mcp_discovery_plan_blocked");
} else {
pushReason(reasonCodes, "guarded_mcp_discovery_plan_needs_clarification");
}
return {
schema_version: ASSISTANT_MCP_DISCOVERY_PLAN_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryPolicy",
plan_status: planStatus,
semantic_data_need: semanticDataNeed,
turn_meaning_ref: turnMeaning,
allowed_primitives: allowedPrimitives,
rejected_primitives: rejectedPrimitives,
required_axes: requiredAxes,
execution_budget: {
max_probe_count: clampInteger(input.maxProbeCount, DEFAULT_DISCOVERY_BUDGET.max_probe_count, 1, MAX_PROBE_COUNT),
max_rows_per_probe: clampInteger(
input.maxRowsPerProbe,
DEFAULT_DISCOVERY_BUDGET.max_rows_per_probe,
1,
MAX_ROWS_PER_PROBE
)
},
requires_evidence_gate: true,
answer_may_use_raw_model_claims: false,
reason_codes: reasonCodes
};
}
function collectProbeLimitations(probeResults: AssistantMcpDiscoveryProbeResult[]): string[] {
const limitations: string[] = [];
for (const probe of probeResults) {
const limitation = toNonEmptyString(probe.limitation);
if (limitation && !limitations.includes(limitation)) {
limitations.push(limitation);
}
}
return limitations;
}
function probeRowsMatched(probeResults: AssistantMcpDiscoveryProbeResult[]): number {
return probeResults.reduce((sum, probe) => {
const rows = Number(probe.rows_matched ?? 0);
return sum + (Number.isFinite(rows) && rows > 0 ? rows : 0);
}, 0);
}
function probeRowsReceived(probeResults: AssistantMcpDiscoveryProbeResult[]): number {
return probeResults.reduce((sum, probe) => {
const rows = Number(probe.rows_received ?? 0);
return sum + (Number.isFinite(rows) && rows > 0 ? rows : 0);
}, 0);
}
function hasProbeBypass(plan: AssistantMcpDiscoveryPlanContract, probeResults: AssistantMcpDiscoveryProbeResult[]): boolean {
const allowed = new Set<string>(plan.allowed_primitives);
return probeResults.some((probe) => !allowed.has(probe.primitive_id));
}
function confidenceReasonFor(status: AssistantMcpDiscoveryEvidenceStatus): string {
if (status === "confirmed") {
return "confirmed_facts_backed_by_allowed_mcp_probe_rows";
}
if (status === "inferred_only") {
return "only_inferred_facts_available_from_allowed_mcp_probe_rows";
}
if (status === "blocked") {
return "runtime_evidence_gate_blocked_discovery_answer";
}
return "allowed_mcp_probes_did_not_produce_sufficient_evidence";
}
export function resolveAssistantMcpDiscoveryEvidence(
input: ResolveAssistantMcpDiscoveryEvidenceInput
): AssistantMcpDiscoveryEvidenceContract {
const probeResults = Array.isArray(input.probeResults) ? input.probeResults : [];
const confirmedFacts = toStringList(input.confirmedFacts);
const inferredFacts = toStringList(input.inferredFacts);
const unknownFacts = toStringList(input.unknownFacts);
const sourceRowsSummary = toNonEmptyString(input.sourceRowsSummary);
const queryLimitations = [
...toStringList(input.queryLimitations),
...collectProbeLimitations(probeResults)
].filter((item, index, all) => all.indexOf(item) === index);
const reasonCodes: string[] = [...input.plan.reason_codes];
const rowsMatched = probeRowsMatched(probeResults);
const rowsReceived = probeRowsReceived(probeResults);
const bypassDetected = hasProbeBypass(input.plan, probeResults);
if (bypassDetected) {
pushReason(reasonCodes, "probe_result_used_primitive_outside_runtime_plan");
}
if (input.plan.plan_status !== "allowed") {
pushReason(reasonCodes, "plan_not_allowed_by_runtime");
}
if (confirmedFacts.length > 0 && rowsMatched <= 0) {
pushReason(reasonCodes, "confirmed_facts_without_matched_probe_rows");
}
if (!sourceRowsSummary && rowsReceived > 0) {
pushReason(reasonCodes, "source_rows_summary_missing");
}
let evidenceStatus: AssistantMcpDiscoveryEvidenceStatus = "insufficient";
let coverageStatus: AssistantMcpDiscoveryCoverageStatus = "blocked";
let answerPermission: AssistantMcpDiscoveryAnswerPermission = "checked_sources_only";
if (bypassDetected || input.plan.plan_status !== "allowed") {
evidenceStatus = "blocked";
coverageStatus = "blocked";
answerPermission = "checked_sources_only";
} else if (confirmedFacts.length > 0 && rowsMatched > 0 && sourceRowsSummary) {
evidenceStatus = "confirmed";
coverageStatus = "full";
answerPermission = "confirmed_answer";
pushReason(reasonCodes, "confirmed_facts_with_allowed_mcp_evidence");
} else if (inferredFacts.length > 0 && rowsReceived > 0) {
evidenceStatus = "inferred_only";
coverageStatus = "partial";
answerPermission = "bounded_inference";
pushReason(reasonCodes, "inferred_facts_require_bounded_answer");
} else {
pushReason(reasonCodes, "mcp_discovery_evidence_insufficient");
}
return {
schema_version: ASSISTANT_MCP_DISCOVERY_EVIDENCE_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryPolicy",
evidence_status: evidenceStatus,
coverage_status: coverageStatus,
answer_permission: answerPermission,
confirmed_facts: confirmedFacts,
inferred_facts: inferredFacts,
unknown_facts: unknownFacts,
source_rows_summary: sourceRowsSummary,
query_plan: input.plan,
query_limitations: queryLimitations,
confidence_reason: confidenceReasonFor(evidenceStatus),
recommended_next_probe: toNonEmptyString(input.recommendedNextProbe),
reason_codes: reasonCodes
};
}

View File

@ -352,6 +352,39 @@ export const INVENTORY_CAPABILITY_CONTRACTS: readonly AssistantCapabilityContrac
] as const; ] as const;
export const ROOT_EXACT_CAPABILITY_CONTRACTS: readonly AssistantCapabilityContract[] = [ export const ROOT_EXACT_CAPABILITY_CONTRACTS: readonly AssistantCapabilityContract[] = [
rootExactCapability({
capability_id: "documents_drilldown",
domainId: "counterparty_documents",
intent_ids: ["list_documents_by_counterparty", "list_documents_by_contract"],
transitions: ["T1", "T2", "T6", "T7"],
requiredAnchors: [],
optionalAnchors: ["organization", "date_scope", "counterparty", "contract", "document_type"],
resultShape: "counterparty_or_contract_document_list",
answerObjectShape: "documents_drilldown_list",
scenarioFamilies: ["canonical", "colloquial", "short_counterparty_retarget", "followup_date_carryover"]
}),
rootExactCapability({
capability_id: "address_customer_revenue_and_payments",
domainId: "counterparty_revenue",
intent_ids: ["customer_revenue_and_payments"],
transitions: ["T1", "T2", "T6", "T7"],
requiredAnchors: [],
optionalAnchors: ["organization", "date_scope", "counterparty", "contract"],
resultShape: "counterparty_revenue_payment_flow",
answerObjectShape: "counterparty_revenue_summary",
scenarioFamilies: ["canonical", "colloquial", "short_counterparty_retarget", "answer_top_block_matches_current_user_intent"]
}),
rootExactCapability({
capability_id: "address_supplier_payouts_profile",
domainId: "counterparty_payouts",
intent_ids: ["supplier_payouts_profile"],
transitions: ["T1", "T2", "T6", "T7"],
requiredAnchors: [],
optionalAnchors: ["organization", "date_scope", "counterparty", "contract"],
resultShape: "supplier_payout_payment_flow",
answerObjectShape: "supplier_payout_summary",
scenarioFamilies: ["canonical", "colloquial", "short_counterparty_retarget"]
}),
rootExactCapability({ rootExactCapability({
capability_id: "confirmed_payables_as_of_date", capability_id: "confirmed_payables_as_of_date",
domainId: "counterparty_debt", domainId: "counterparty_debt",

View File

@ -119,6 +119,20 @@ describe("address capability policy", () => {
expect(decision.capability_route_enabled).toBe(true); expect(decision.capability_route_enabled).toBe(true);
}); });
it("maps counterparty value-flow intents to exact compute capabilities", () => {
const revenue = resolveAddressCapabilityRouteDecision("customer_revenue_and_payments");
expect(revenue.capability_id).toBe("address_customer_revenue_and_payments");
expect(revenue.capability_layer).toBe("compute");
expect(revenue.capability_route_mode).toBe("exact");
expect(revenue.capability_route_enabled).toBe(true);
expect(revenue.capability_route_reason).toBe("counterparty_value_flow_route_enabled");
const payouts = resolveAddressCapabilityRouteDecision("supplier_payouts_profile");
expect(payouts.capability_id).toBe("address_supplier_payouts_profile");
expect(payouts.capability_layer).toBe("compute");
expect(payouts.capability_route_mode).toBe("exact");
});
it("maps heuristic list intents to heuristic compute route mode", () => { it("maps heuristic list intents to heuristic compute route mode", () => {
const decision = resolveAddressCapabilityRouteDecision("list_receivables_counterparties"); const decision = resolveAddressCapabilityRouteDecision("list_receivables_counterparties");
expect(decision.capability_id).toBe("receivables_candidates_list"); expect(decision.capability_id).toBe("receivables_candidates_list");

View File

@ -76,6 +76,35 @@ describe("assistant capability runtime binding adapter", () => {
expect(binding.violations).toEqual([]); expect(binding.violations).toEqual([]);
}); });
it("binds counterparty revenue answers to the phase18 exact contract", () => {
const binding = resolveAssistantCapabilityRuntimeBinding({
addressDebug: {
capability_id: "address_customer_revenue_and_payments",
detected_intent: "customer_revenue_and_payments",
detected_mode: "address_query",
capability_layer: "compute",
capability_route_mode: "exact",
extracted_filters: {
counterparty: "Группа СВК",
organization: "ООО Альтернатива Плюс"
},
rows_matched: 16,
route_expectation_status: "matched"
},
groundingStatus: "grounded",
replyType: "factual"
});
expect(binding.binding_status).toBe("bound");
expect(binding.binding_action).toBe("allow");
expect(binding.capability_contract_id).toBe("address_customer_revenue_and_payments");
expect(binding.transition_id).toBe("T1");
expect(binding.transition_allowed).toBe(true);
expect(binding.result_shape).toBe("counterparty_revenue_payment_flow");
expect(binding.provided_anchors).toEqual(expect.arrayContaining(["counterparty", "organization"]));
expect(binding.violations).toEqual([]);
});
it("blocks selected-object capabilities when required anchors are missing", () => { it("blocks selected-object capabilities when required anchors are missing", () => {
const binding = resolveAssistantCapabilityRuntimeBinding({ const binding = resolveAssistantCapabilityRuntimeBinding({
addressDebug: { addressDebug: {

View File

@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import {
buildAssistantMcpDiscoveryPlan,
isAssistantMcpDiscoveryPrimitive,
resolveAssistantMcpDiscoveryEvidence
} from "../src/services/assistantMcpDiscoveryPolicy";
describe("assistant MCP discovery policy", () => {
it("allows guarded MCP primitives and keeps raw model claims outside the answer path", () => {
const plan = buildAssistantMcpDiscoveryPlan({
semanticDataNeed: "counterparty turnover evidence",
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "turnover",
explicit_entity_candidates: ["Группа СВК"],
explicit_date_scope: "2020"
},
proposedPrimitives: ["resolve_entity_reference", "query_movements", "drop_database"],
requiredAxes: ["counterparty", "period", "amount"],
maxProbeCount: 99,
maxRowsPerProbe: 9999
});
expect(plan.plan_status).toBe("allowed");
expect(plan.allowed_primitives).toEqual(["resolve_entity_reference", "query_movements"]);
expect(plan.rejected_primitives).toEqual(["drop_database"]);
expect(plan.requires_evidence_gate).toBe(true);
expect(plan.answer_may_use_raw_model_claims).toBe(false);
expect(plan.execution_budget).toEqual({ max_probe_count: 6, max_rows_per_probe: 500 });
expect(plan.reason_codes).toContain("model_proposed_unregistered_mcp_primitive");
});
it("blocks model-planned probes when no proposed primitive survives the runtime allowlist", () => {
const plan = buildAssistantMcpDiscoveryPlan({
semanticDataNeed: "direct SQL from model",
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "turnover",
explicit_entity_candidates: ["СВК"]
},
proposedPrimitives: ["raw_sql", "filesystem_read"],
requiredAxes: ["counterparty"]
});
expect(plan.plan_status).toBe("blocked");
expect(plan.allowed_primitives).toEqual([]);
expect(plan.rejected_primitives).toEqual(["raw_sql", "filesystem_read"]);
expect(plan.reason_codes).toContain("no_allowed_mcp_primitives_after_runtime_filter");
});
it("separates confirmed, inferred and unknown facts before answer composition", () => {
const plan = buildAssistantMcpDiscoveryPlan({
semanticDataNeed: "activity duration from 1C evidence",
turnMeaning: {
asked_domain_family: "counterparty_lifecycle",
asked_action_family: "activity_duration",
explicit_entity_candidates: ["СВК"]
},
proposedPrimitives: ["resolve_entity_reference", "query_documents", "probe_coverage"],
requiredAxes: ["counterparty", "document_date"]
});
const evidence = resolveAssistantMcpDiscoveryEvidence({
plan,
probeResults: [
{ primitive_id: "resolve_entity_reference", status: "ok", rows_received: 1, rows_matched: 1 },
{ primitive_id: "query_documents", status: "ok", rows_received: 4, rows_matched: 4 }
],
confirmedFacts: ["first confirmed 1C activity is 2020-01-15"],
inferredFacts: ["activity duration can be estimated from first and latest 1C activity"],
unknownFacts: ["legal registration date is not proven by these rows"],
sourceRowsSummary: "5 allowed MCP rows: 1 entity match, 4 documents"
});
expect(evidence.evidence_status).toBe("confirmed");
expect(evidence.coverage_status).toBe("full");
expect(evidence.answer_permission).toBe("confirmed_answer");
expect(evidence.confirmed_facts).toHaveLength(1);
expect(evidence.inferred_facts).toHaveLength(1);
expect(evidence.unknown_facts).toHaveLength(1);
expect(evidence.reason_codes).toContain("confirmed_facts_with_allowed_mcp_evidence");
});
it("permits only bounded inference when probes found rows but no confirmed fact", () => {
const plan = buildAssistantMcpDiscoveryPlan({
semanticDataNeed: "counterparty business age inference",
turnMeaning: {
asked_domain_family: "counterparty_lifecycle",
asked_action_family: "age_or_activity_duration",
explicit_entity_candidates: ["СВК"]
},
proposedPrimitives: ["query_documents"],
requiredAxes: ["counterparty", "document_date"]
});
const evidence = resolveAssistantMcpDiscoveryEvidence({
plan,
probeResults: [{ primitive_id: "query_documents", status: "ok", rows_received: 3, rows_matched: 0 }],
inferredFacts: ["activity is visible in 1C documents for 2020"],
unknownFacts: ["legal age remains unknown"],
sourceRowsSummary: "3 document rows checked"
});
expect(evidence.evidence_status).toBe("inferred_only");
expect(evidence.coverage_status).toBe("partial");
expect(evidence.answer_permission).toBe("bounded_inference");
expect(evidence.confidence_reason).toBe("only_inferred_facts_available_from_allowed_mcp_probe_rows");
});
it("blocks evidence when execution reports a primitive outside the runtime plan", () => {
const plan = buildAssistantMcpDiscoveryPlan({
semanticDataNeed: "counterparty turnover evidence",
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "turnover",
explicit_entity_candidates: ["СВК"]
},
proposedPrimitives: ["query_movements"],
requiredAxes: ["counterparty"]
});
const evidence = resolveAssistantMcpDiscoveryEvidence({
plan,
probeResults: [
{ primitive_id: "query_movements", status: "ok", rows_received: 2, rows_matched: 2 },
{ primitive_id: "raw_sql", status: "ok", rows_received: 10, rows_matched: 10 }
],
confirmedFacts: ["turnover is 100"],
sourceRowsSummary: "12 rows"
});
expect(evidence.evidence_status).toBe("blocked");
expect(evidence.answer_permission).toBe("checked_sources_only");
expect(evidence.reason_codes).toContain("probe_result_used_primitive_outside_runtime_plan");
});
it("exports the reviewed primitive predicate for future runtime adapters", () => {
expect(isAssistantMcpDiscoveryPrimitive("query_movements")).toBe(true);
expect(isAssistantMcpDiscoveryPrimitive("raw_sql")).toBe(false);
});
});

View File

@ -95,6 +95,20 @@ describe("assistant runtime contract registry", () => {
expect(contract?.execution_adapter).toBe("AddressQueryService"); expect(contract?.execution_adapter).toBe("AddressQueryService");
}); });
it("declares counterparty document and value-flow contracts used by semantic dialog authority replay", () => {
const documents = getAssistantCapabilityContractByIntent("list_documents_by_counterparty");
expect(documents?.capability_id).toBe("documents_drilldown");
expect(documents?.supported_transition_classes).toEqual(["T1", "T2", "T6", "T7"]);
expect(documents?.result_shape).toBe("counterparty_or_contract_document_list");
const revenue = getAssistantCapabilityContractByIntent("customer_revenue_and_payments");
expect(revenue?.capability_id).toBe("address_customer_revenue_and_payments");
expect(revenue?.runtime_lane).toBe("address_exact");
expect(revenue?.requires_focus_object).toBe(false);
expect(revenue?.supported_transition_classes).toEqual(["T1", "T2", "T6", "T7"]);
expect(revenue?.required_scenario_families).toContain("answer_top_block_matches_current_user_intent");
});
it("keeps truth semantics outside answer wording for every pilot inventory capability", () => { it("keeps truth semantics outside answer wording for every pilot inventory capability", () => {
for (const contract of listInventoryCapabilityContracts()) { for (const contract of listInventoryCapabilityContracts()) {
expect(contract.coverage_gate_behavior).toBe("partial_or_blocked_if_evidence_insufficient"); expect(contract.coverage_gate_behavior).toBe("partial_or_blocked_if_evidence_insufficient");
@ -130,6 +144,27 @@ describe("assistant runtime contract registry", () => {
expect(decision.carryover_eligibility).toBe("object_only"); expect(decision.carryover_eligibility).toBe("object_only");
}); });
it("resolves phase18 counterparty revenue shadow as a root exact contract", () => {
const decision = resolveAssistantRuntimeContractShadow({
addressDebug: {
capability_id: "address_customer_revenue_and_payments",
detected_intent: "customer_revenue_and_payments",
detected_mode: "address_query",
capability_layer: "compute",
capability_route_mode: "exact",
rows_matched: 16,
route_expectation_status: "matched"
},
groundingStatus: "grounded"
});
expect(decision.transition_contract_id).toBe("T1");
expect(decision.capability_contract_id).toBe("address_customer_revenue_and_payments");
expect(decision.capability_contract_reason).toEqual(["debug_capability_id_matched_contract"]);
expect(decision.truth_gate_contract_status).toBe("full_confirmed");
expect(decision.carryover_eligibility).toBe("root_only");
});
it("resolves meta follow-up and blocked route expectation in shadow mode", () => { it("resolves meta follow-up and blocked route expectation in shadow mode", () => {
const metaDecision = resolveAssistantRuntimeContractShadow({ const metaDecision = resolveAssistantRuntimeContractShadow({
addressRuntimeMeta: { addressRuntimeMeta: {