ARCH: добавить candidate ответа MCP discovery
This commit is contained in:
parent
f0d7e81ec0
commit
58c1358960
|
|
@ -945,6 +945,31 @@ Validation:
|
||||||
- `npm test -- assistantMcpDiscoveryRuntimeEntryPoint.test.ts assistantMcpDiscoveryTurnInputAdapter.test.ts assistantMcpDiscoveryPolicy.test.ts assistantMcpCatalogIndex.test.ts assistantMcpDiscoveryPlanner.test.ts assistantMcpDiscoveryRuntimeAdapter.test.ts assistantMcpDiscoveryPilotExecutor.test.ts assistantMcpDiscoveryAnswerAdapter.test.ts assistantMcpDiscoveryRuntimeBridge.test.ts assistantMcpDiscoveryDebugAttachment.test.ts assistantAddressOrchestrationRuntimeAdapter.test.ts assistantAddressLaneResponseRuntimeAdapter.test.ts assistantDebugPayloadAssembler.test.ts` passed 61/61;
|
- `npm test -- assistantMcpDiscoveryRuntimeEntryPoint.test.ts assistantMcpDiscoveryTurnInputAdapter.test.ts assistantMcpDiscoveryPolicy.test.ts assistantMcpCatalogIndex.test.ts assistantMcpDiscoveryPlanner.test.ts assistantMcpDiscoveryRuntimeAdapter.test.ts assistantMcpDiscoveryPilotExecutor.test.ts assistantMcpDiscoveryAnswerAdapter.test.ts assistantMcpDiscoveryRuntimeBridge.test.ts assistantMcpDiscoveryDebugAttachment.test.ts assistantAddressOrchestrationRuntimeAdapter.test.ts assistantAddressLaneResponseRuntimeAdapter.test.ts assistantDebugPayloadAssembler.test.ts` passed 61/61;
|
||||||
- `npm run build` passed.
|
- `npm run build` passed.
|
||||||
|
|
||||||
|
## Progress Update - 2026-04-20 MCP Discovery Response Candidate
|
||||||
|
|
||||||
|
The twelfth implementation slice of Big Block 5 added a guarded response-candidate layer:
|
||||||
|
|
||||||
|
- `assistantMcpDiscoveryResponseCandidate.ts`
|
||||||
|
- `assistantMcpDiscoveryResponseCandidate.test.ts`
|
||||||
|
|
||||||
|
This layer still does not replace the final user-facing assistant answer.
|
||||||
|
|
||||||
|
It converts a validated MCP discovery entry point into a future-use answer candidate:
|
||||||
|
|
||||||
|
- Russian reply text for confirmed, inferred, unknown, limitation, and clarification sections;
|
||||||
|
- `hot_runtime_wired=false`;
|
||||||
|
- `eligible_for_future_hot_runtime`;
|
||||||
|
- `must_keep_internal_mechanics_hidden=true`;
|
||||||
|
- internal primitive/query/runtime mechanics are filtered out;
|
||||||
|
- unsupported discovery output is not exposed as a future hot candidate.
|
||||||
|
|
||||||
|
This creates the final guarded buffer needed before any explicit answer replacement policy can be introduced.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
|
||||||
|
- `npm test -- assistantMcpDiscoveryResponseCandidate.test.ts assistantMcpDiscoveryRuntimeEntryPoint.test.ts assistantMcpDiscoveryDebugAttachment.test.ts assistantMcpDiscoveryAnswerAdapter.test.ts assistantMcpDiscoveryPilotExecutor.test.ts` passed 17/17;
|
||||||
|
- `npm run build` passed.
|
||||||
|
|
||||||
## Execution Rule
|
## Execution Rule
|
||||||
|
|
||||||
Do not implement this plan as:
|
Do not implement this plan as:
|
||||||
|
|
|
||||||
159
llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js
vendored
Normal file
159
llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js
vendored
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.ASSISTANT_MCP_DISCOVERY_RESPONSE_CANDIDATE_SCHEMA_VERSION = void 0;
|
||||||
|
exports.buildAssistantMcpDiscoveryResponseCandidate = buildAssistantMcpDiscoveryResponseCandidate;
|
||||||
|
exports.ASSISTANT_MCP_DISCOVERY_RESPONSE_CANDIDATE_SCHEMA_VERSION = "assistant_mcp_discovery_response_candidate_v1";
|
||||||
|
function toRecordObject(value) {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
return value.map((item) => toNonEmptyString(item)).filter((item) => Boolean(item));
|
||||||
|
}
|
||||||
|
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 uniqueStrings(values) {
|
||||||
|
const result = [];
|
||||||
|
for (const value of values) {
|
||||||
|
const text = String(value ?? "").trim();
|
||||||
|
if (text && !result.includes(text)) {
|
||||||
|
result.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function hasInternalMechanics(value) {
|
||||||
|
const text = value.toLowerCase();
|
||||||
|
return (text.includes("query_documents") ||
|
||||||
|
text.includes("query_movements") ||
|
||||||
|
text.includes("primitive") ||
|
||||||
|
text.includes("runtime_") ||
|
||||||
|
text.includes("planner_") ||
|
||||||
|
text.includes("catalog_") ||
|
||||||
|
text.includes("select "));
|
||||||
|
}
|
||||||
|
function userFacingLines(values) {
|
||||||
|
return uniqueStrings(values).filter((line) => !hasInternalMechanics(line));
|
||||||
|
}
|
||||||
|
function localizeLine(value) {
|
||||||
|
const counterpartyMatch = value.match(/^1C activity rows were found for counterparty\s+(.+)$/i);
|
||||||
|
if (counterpartyMatch) {
|
||||||
|
return `В 1С найдены строки активности по контрагенту ${counterpartyMatch[1]}.`;
|
||||||
|
}
|
||||||
|
if (/^1C activity rows were found for the requested counterparty scope$/i.test(value)) {
|
||||||
|
return "В 1С найдены строки активности по запрошенному контрагентскому контуру.";
|
||||||
|
}
|
||||||
|
if (/^Business activity duration may be inferred from first and latest confirmed 1C activity rows$/i.test(value)) {
|
||||||
|
return "Длительность деловой активности можно оценивать только как вывод по первой и последней подтвержденной строке активности в 1С.";
|
||||||
|
}
|
||||||
|
if (/^Legal registration date is not proven by this MCP discovery pilot$/i.test(value)) {
|
||||||
|
return "Юридическая дата регистрации этим поиском не подтверждена.";
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function section(title, lines) {
|
||||||
|
const clean = userFacingLines(lines.map(localizeLine));
|
||||||
|
if (clean.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `${title}\n${clean.map((line) => `- ${line}`).join("\n")}`;
|
||||||
|
}
|
||||||
|
function statusFrom(entryPoint) {
|
||||||
|
if (!entryPoint || entryPoint.entry_status === "skipped_not_applicable") {
|
||||||
|
return "not_applicable";
|
||||||
|
}
|
||||||
|
if (entryPoint.entry_status === "skipped_needs_more_context") {
|
||||||
|
return "clarification_candidate";
|
||||||
|
}
|
||||||
|
const bridgeStatus = toNonEmptyString(toRecordObject(entryPoint.bridge)?.bridge_status);
|
||||||
|
if (bridgeStatus === "answer_draft_ready") {
|
||||||
|
return "ready_for_guarded_use";
|
||||||
|
}
|
||||||
|
if (bridgeStatus === "needs_clarification") {
|
||||||
|
return "clarification_candidate";
|
||||||
|
}
|
||||||
|
if (bridgeStatus === "checked_sources_only") {
|
||||||
|
return "checked_sources_only_candidate";
|
||||||
|
}
|
||||||
|
if (bridgeStatus === "blocked") {
|
||||||
|
return "blocked";
|
||||||
|
}
|
||||||
|
if (bridgeStatus === "unsupported") {
|
||||||
|
return "unsupported";
|
||||||
|
}
|
||||||
|
return "not_applicable";
|
||||||
|
}
|
||||||
|
function replyTypeFor(status) {
|
||||||
|
if (status === "clarification_candidate") {
|
||||||
|
return "clarification_required";
|
||||||
|
}
|
||||||
|
if (status === "blocked" || status === "unsupported" || status === "not_applicable") {
|
||||||
|
return "no_grounded_answer";
|
||||||
|
}
|
||||||
|
return "partial_coverage";
|
||||||
|
}
|
||||||
|
function buildReplyText(entryPoint, status) {
|
||||||
|
const bridge = toRecordObject(entryPoint.bridge);
|
||||||
|
const draft = toRecordObject(bridge?.answer_draft);
|
||||||
|
if (!draft) {
|
||||||
|
if (status === "clarification_candidate") {
|
||||||
|
return "Нужно уточнить контекст перед поиском в 1С: контрагента, период или организацию.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const blocks = [
|
||||||
|
toNonEmptyString(draft.headline) ? `Коротко: ${localizeLine(String(draft.headline))}` : null,
|
||||||
|
section("Что подтверждено:", toStringList(draft.confirmed_lines)),
|
||||||
|
section("Что можно сказать только как вывод:", toStringList(draft.inference_lines)),
|
||||||
|
section("Что не подтверждено:", toStringList(draft.unknown_lines)),
|
||||||
|
section("Ограничения проверки:", toStringList(draft.limitation_lines)),
|
||||||
|
toNonEmptyString(draft.next_step_line) ? `Следующий шаг: ${localizeLine(String(draft.next_step_line))}` : null
|
||||||
|
].filter((item) => Boolean(item));
|
||||||
|
const reply = blocks.join("\n\n").trim();
|
||||||
|
return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null;
|
||||||
|
}
|
||||||
|
function buildAssistantMcpDiscoveryResponseCandidate(entryPoint) {
|
||||||
|
const entry = entryPoint ?? null;
|
||||||
|
const status = statusFrom(entry);
|
||||||
|
const reasonCodes = uniqueStrings(entry?.reason_codes ?? []);
|
||||||
|
pushReason(reasonCodes, `mcp_discovery_response_candidate_${status}`);
|
||||||
|
pushReason(reasonCodes, "mcp_discovery_response_candidate_not_hot_wired");
|
||||||
|
const replyText = entry && (status === "ready_for_guarded_use" || status === "checked_sources_only_candidate" || status === "clarification_candidate")
|
||||||
|
? buildReplyText(entry, status)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
schema_version: exports.ASSISTANT_MCP_DISCOVERY_RESPONSE_CANDIDATE_SCHEMA_VERSION,
|
||||||
|
policy_owner: "assistantMcpDiscoveryResponseCandidate",
|
||||||
|
candidate_status: replyText ? status : status === "clarification_candidate" ? status : status,
|
||||||
|
hot_runtime_wired: false,
|
||||||
|
reply_type: replyTypeFor(status),
|
||||||
|
reply_text: replyText,
|
||||||
|
eligible_for_future_hot_runtime: Boolean(replyText) && (status === "ready_for_guarded_use" || status === "checked_sources_only_candidate" || status === "clarification_candidate"),
|
||||||
|
must_keep_internal_mechanics_hidden: true,
|
||||||
|
reason_codes: reasonCodes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
import type { AssistantMcpDiscoveryRuntimeEntryPointContract } from "./assistantMcpDiscoveryRuntimeEntryPoint";
|
||||||
|
|
||||||
|
export const ASSISTANT_MCP_DISCOVERY_RESPONSE_CANDIDATE_SCHEMA_VERSION =
|
||||||
|
"assistant_mcp_discovery_response_candidate_v1" as const;
|
||||||
|
|
||||||
|
export type AssistantMcpDiscoveryResponseCandidateStatus =
|
||||||
|
| "ready_for_guarded_use"
|
||||||
|
| "clarification_candidate"
|
||||||
|
| "checked_sources_only_candidate"
|
||||||
|
| "not_applicable"
|
||||||
|
| "blocked"
|
||||||
|
| "unsupported";
|
||||||
|
|
||||||
|
export interface AssistantMcpDiscoveryResponseCandidateContract {
|
||||||
|
schema_version: typeof ASSISTANT_MCP_DISCOVERY_RESPONSE_CANDIDATE_SCHEMA_VERSION;
|
||||||
|
policy_owner: "assistantMcpDiscoveryResponseCandidate";
|
||||||
|
candidate_status: AssistantMcpDiscoveryResponseCandidateStatus;
|
||||||
|
hot_runtime_wired: false;
|
||||||
|
reply_type: "partial_coverage" | "clarification_required" | "no_grounded_answer";
|
||||||
|
reply_text: string | null;
|
||||||
|
eligible_for_future_hot_runtime: boolean;
|
||||||
|
must_keep_internal_mechanics_hidden: true;
|
||||||
|
reason_codes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRecordObject(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNonEmptyString(value: unknown): string | null {
|
||||||
|
if (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 [];
|
||||||
|
}
|
||||||
|
return value.map((item) => toNonEmptyString(item)).filter((item): item is string => Boolean(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 uniqueStrings(values: string[]): string[] {
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const value of values) {
|
||||||
|
const text = String(value ?? "").trim();
|
||||||
|
if (text && !result.includes(text)) {
|
||||||
|
result.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasInternalMechanics(value: string): boolean {
|
||||||
|
const text = value.toLowerCase();
|
||||||
|
return (
|
||||||
|
text.includes("query_documents") ||
|
||||||
|
text.includes("query_movements") ||
|
||||||
|
text.includes("primitive") ||
|
||||||
|
text.includes("runtime_") ||
|
||||||
|
text.includes("planner_") ||
|
||||||
|
text.includes("catalog_") ||
|
||||||
|
text.includes("select ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function userFacingLines(values: string[]): string[] {
|
||||||
|
return uniqueStrings(values).filter((line) => !hasInternalMechanics(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
function localizeLine(value: string): string {
|
||||||
|
const counterpartyMatch = value.match(/^1C activity rows were found for counterparty\s+(.+)$/i);
|
||||||
|
if (counterpartyMatch) {
|
||||||
|
return `В 1С найдены строки активности по контрагенту ${counterpartyMatch[1]}.`;
|
||||||
|
}
|
||||||
|
if (/^1C activity rows were found for the requested counterparty scope$/i.test(value)) {
|
||||||
|
return "В 1С найдены строки активности по запрошенному контрагентскому контуру.";
|
||||||
|
}
|
||||||
|
if (/^Business activity duration may be inferred from first and latest confirmed 1C activity rows$/i.test(value)) {
|
||||||
|
return "Длительность деловой активности можно оценивать только как вывод по первой и последней подтвержденной строке активности в 1С.";
|
||||||
|
}
|
||||||
|
if (/^Legal registration date is not proven by this MCP discovery pilot$/i.test(value)) {
|
||||||
|
return "Юридическая дата регистрации этим поиском не подтверждена.";
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function section(title: string, lines: string[]): string | null {
|
||||||
|
const clean = userFacingLines(lines.map(localizeLine));
|
||||||
|
if (clean.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `${title}\n${clean.map((line) => `- ${line}`).join("\n")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusFrom(entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null): AssistantMcpDiscoveryResponseCandidateStatus {
|
||||||
|
if (!entryPoint || entryPoint.entry_status === "skipped_not_applicable") {
|
||||||
|
return "not_applicable";
|
||||||
|
}
|
||||||
|
if (entryPoint.entry_status === "skipped_needs_more_context") {
|
||||||
|
return "clarification_candidate";
|
||||||
|
}
|
||||||
|
const bridgeStatus = toNonEmptyString(toRecordObject(entryPoint.bridge)?.bridge_status);
|
||||||
|
if (bridgeStatus === "answer_draft_ready") {
|
||||||
|
return "ready_for_guarded_use";
|
||||||
|
}
|
||||||
|
if (bridgeStatus === "needs_clarification") {
|
||||||
|
return "clarification_candidate";
|
||||||
|
}
|
||||||
|
if (bridgeStatus === "checked_sources_only") {
|
||||||
|
return "checked_sources_only_candidate";
|
||||||
|
}
|
||||||
|
if (bridgeStatus === "blocked") {
|
||||||
|
return "blocked";
|
||||||
|
}
|
||||||
|
if (bridgeStatus === "unsupported") {
|
||||||
|
return "unsupported";
|
||||||
|
}
|
||||||
|
return "not_applicable";
|
||||||
|
}
|
||||||
|
|
||||||
|
function replyTypeFor(
|
||||||
|
status: AssistantMcpDiscoveryResponseCandidateStatus
|
||||||
|
): AssistantMcpDiscoveryResponseCandidateContract["reply_type"] {
|
||||||
|
if (status === "clarification_candidate") {
|
||||||
|
return "clarification_required";
|
||||||
|
}
|
||||||
|
if (status === "blocked" || status === "unsupported" || status === "not_applicable") {
|
||||||
|
return "no_grounded_answer";
|
||||||
|
}
|
||||||
|
return "partial_coverage";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReplyText(entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract, status: AssistantMcpDiscoveryResponseCandidateStatus): string | null {
|
||||||
|
const bridge = toRecordObject(entryPoint.bridge);
|
||||||
|
const draft = toRecordObject(bridge?.answer_draft);
|
||||||
|
if (!draft) {
|
||||||
|
if (status === "clarification_candidate") {
|
||||||
|
return "Нужно уточнить контекст перед поиском в 1С: контрагента, период или организацию.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks = [
|
||||||
|
toNonEmptyString(draft.headline) ? `Коротко: ${localizeLine(String(draft.headline))}` : null,
|
||||||
|
section("Что подтверждено:", toStringList(draft.confirmed_lines)),
|
||||||
|
section("Что можно сказать только как вывод:", toStringList(draft.inference_lines)),
|
||||||
|
section("Что не подтверждено:", toStringList(draft.unknown_lines)),
|
||||||
|
section("Ограничения проверки:", toStringList(draft.limitation_lines)),
|
||||||
|
toNonEmptyString(draft.next_step_line) ? `Следующий шаг: ${localizeLine(String(draft.next_step_line))}` : null
|
||||||
|
].filter((item): item is string => Boolean(item));
|
||||||
|
|
||||||
|
const reply = blocks.join("\n\n").trim();
|
||||||
|
return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAssistantMcpDiscoveryResponseCandidate(
|
||||||
|
entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null | undefined
|
||||||
|
): AssistantMcpDiscoveryResponseCandidateContract {
|
||||||
|
const entry = entryPoint ?? null;
|
||||||
|
const status = statusFrom(entry);
|
||||||
|
const reasonCodes = uniqueStrings(entry?.reason_codes ?? []);
|
||||||
|
pushReason(reasonCodes, `mcp_discovery_response_candidate_${status}`);
|
||||||
|
pushReason(reasonCodes, "mcp_discovery_response_candidate_not_hot_wired");
|
||||||
|
|
||||||
|
const replyText =
|
||||||
|
entry && (status === "ready_for_guarded_use" || status === "checked_sources_only_candidate" || status === "clarification_candidate")
|
||||||
|
? buildReplyText(entry, status)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
schema_version: ASSISTANT_MCP_DISCOVERY_RESPONSE_CANDIDATE_SCHEMA_VERSION,
|
||||||
|
policy_owner: "assistantMcpDiscoveryResponseCandidate",
|
||||||
|
candidate_status: replyText ? status : status === "clarification_candidate" ? status : status,
|
||||||
|
hot_runtime_wired: false,
|
||||||
|
reply_type: replyTypeFor(status),
|
||||||
|
reply_text: replyText,
|
||||||
|
eligible_for_future_hot_runtime:
|
||||||
|
Boolean(replyText) && (status === "ready_for_guarded_use" || status === "checked_sources_only_candidate" || status === "clarification_candidate"),
|
||||||
|
must_keep_internal_mechanics_hidden: true,
|
||||||
|
reason_codes: reasonCodes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildAssistantMcpDiscoveryResponseCandidate } from "../src/services/assistantMcpDiscoveryResponseCandidate";
|
||||||
|
|
||||||
|
function entryPoint(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
schema_version: "assistant_mcp_discovery_runtime_entry_point_v1",
|
||||||
|
policy_owner: "assistantMcpDiscoveryRuntimeEntryPoint",
|
||||||
|
entry_status: "bridge_executed",
|
||||||
|
hot_runtime_wired: false,
|
||||||
|
discovery_attempted: true,
|
||||||
|
turn_input: { adapter_status: "ready" },
|
||||||
|
bridge: {
|
||||||
|
bridge_status: "answer_draft_ready",
|
||||||
|
user_facing_response_allowed: true,
|
||||||
|
business_fact_answer_allowed: true,
|
||||||
|
requires_user_clarification: false,
|
||||||
|
answer_draft: {
|
||||||
|
answer_mode: "confirmed_with_bounded_inference",
|
||||||
|
headline: "По данным 1С есть подтвержденная активность; длительность можно оценивать только как вывод.",
|
||||||
|
confirmed_lines: ["1C activity rows were found for counterparty SVK"],
|
||||||
|
inference_lines: ["Business activity duration may be inferred from first and latest confirmed 1C activity rows"],
|
||||||
|
unknown_lines: ["Legal registration date is not proven by this MCP discovery pilot"],
|
||||||
|
limitation_lines: ["query_documents was skipped internally", "MCP fetch window was limited"],
|
||||||
|
next_step_line: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reason_codes: ["runtime_entry_point_bridge_executed"],
|
||||||
|
...overrides
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("assistant MCP discovery response candidate", () => {
|
||||||
|
it("builds a Russian guarded candidate from a confirmed discovery draft", () => {
|
||||||
|
const candidate = buildAssistantMcpDiscoveryResponseCandidate(entryPoint());
|
||||||
|
|
||||||
|
expect(candidate.candidate_status).toBe("ready_for_guarded_use");
|
||||||
|
expect(candidate.hot_runtime_wired).toBe(false);
|
||||||
|
expect(candidate.reply_type).toBe("partial_coverage");
|
||||||
|
expect(candidate.eligible_for_future_hot_runtime).toBe(true);
|
||||||
|
expect(candidate.reply_text).toContain("Коротко:");
|
||||||
|
expect(candidate.reply_text).toContain("В 1С найдены строки активности по контрагенту SVK.");
|
||||||
|
expect(candidate.reply_text).toContain("Юридическая дата регистрации этим поиском не подтверждена.");
|
||||||
|
expect(candidate.reply_text).not.toContain("query_documents");
|
||||||
|
expect(candidate.reply_text).not.toContain("primitive");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns not applicable when discovery was skipped for an exact supported route", () => {
|
||||||
|
const candidate = buildAssistantMcpDiscoveryResponseCandidate({
|
||||||
|
schema_version: "assistant_mcp_discovery_runtime_entry_point_v1",
|
||||||
|
policy_owner: "assistantMcpDiscoveryRuntimeEntryPoint",
|
||||||
|
entry_status: "skipped_not_applicable",
|
||||||
|
hot_runtime_wired: false,
|
||||||
|
discovery_attempted: false,
|
||||||
|
turn_input: { adapter_status: "not_applicable" },
|
||||||
|
bridge: null,
|
||||||
|
reason_codes: []
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(candidate.candidate_status).toBe("not_applicable");
|
||||||
|
expect(candidate.reply_text).toBeNull();
|
||||||
|
expect(candidate.eligible_for_future_hot_runtime).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a clarification candidate from a clarification bridge", () => {
|
||||||
|
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
|
||||||
|
entryPoint({
|
||||||
|
bridge: {
|
||||||
|
bridge_status: "needs_clarification",
|
||||||
|
user_facing_response_allowed: true,
|
||||||
|
business_fact_answer_allowed: false,
|
||||||
|
requires_user_clarification: true,
|
||||||
|
answer_draft: {
|
||||||
|
answer_mode: "needs_clarification",
|
||||||
|
headline: "Нужно уточнить контекст перед поиском в 1С.",
|
||||||
|
confirmed_lines: [],
|
||||||
|
inference_lines: [],
|
||||||
|
unknown_lines: [],
|
||||||
|
limitation_lines: [],
|
||||||
|
next_step_line: "Уточните контрагента, период или организацию."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(candidate.candidate_status).toBe("clarification_candidate");
|
||||||
|
expect(candidate.reply_type).toBe("clarification_required");
|
||||||
|
expect(candidate.reply_text).toContain("Нужно уточнить контекст");
|
||||||
|
expect(candidate.eligible_for_future_hot_runtime).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expose unsupported bridge output as a future hot candidate", () => {
|
||||||
|
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
|
||||||
|
entryPoint({
|
||||||
|
bridge: {
|
||||||
|
bridge_status: "unsupported",
|
||||||
|
user_facing_response_allowed: true,
|
||||||
|
business_fact_answer_allowed: false,
|
||||||
|
requires_user_clarification: false,
|
||||||
|
answer_draft: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(candidate.candidate_status).toBe("unsupported");
|
||||||
|
expect(candidate.reply_type).toBe("no_grounded_answer");
|
||||||
|
expect(candidate.reply_text).toBeNull();
|
||||||
|
expect(candidate.eligible_for_future_hot_runtime).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue