ARCH: добавить answer adapter MCP discovery
This commit is contained in:
parent
4181bb9c9b
commit
b4865f36b1
|
|
@ -788,6 +788,36 @@ Validation:
|
|||
- `npm test -- assistantMcpDiscoveryPolicy.test.ts assistantMcpCatalogIndex.test.ts assistantMcpDiscoveryPlanner.test.ts assistantMcpDiscoveryRuntimeAdapter.test.ts assistantMcpDiscoveryPilotExecutor.test.ts` passed 25/25;
|
||||
- `npm run build` passed.
|
||||
|
||||
## Progress Update - 2026-04-20 MCP Discovery Answer Adapter
|
||||
|
||||
The sixth implementation slice of Big Block 5 added a human-safe answer draft adapter:
|
||||
|
||||
- `assistantMcpDiscoveryAnswerAdapter.ts`
|
||||
- `assistantMcpDiscoveryAnswerAdapter.test.ts`
|
||||
|
||||
This adapter is still not wired into the hot assistant runtime.
|
||||
|
||||
It converts pilot evidence into an answer draft that can later be consumed by the final answer layer:
|
||||
|
||||
- confirmed lines;
|
||||
- bounded inference lines;
|
||||
- unknown fact boundaries;
|
||||
- user-facing limitations;
|
||||
- next-step guidance;
|
||||
- `must_not_claim` constraints.
|
||||
|
||||
The adapter explicitly blocks internal mechanics from user-facing lines:
|
||||
|
||||
- MCP primitive names;
|
||||
- query text;
|
||||
- debug/reason mechanics;
|
||||
- raw runtime/planner/catalog codes.
|
||||
|
||||
Validation:
|
||||
|
||||
- `npm test -- assistantMcpDiscoveryPolicy.test.ts assistantMcpCatalogIndex.test.ts assistantMcpDiscoveryPlanner.test.ts assistantMcpDiscoveryRuntimeAdapter.test.ts assistantMcpDiscoveryPilotExecutor.test.ts assistantMcpDiscoveryAnswerAdapter.test.ts` passed 29/29;
|
||||
- `npm run build` passed.
|
||||
|
||||
## Execution Rule
|
||||
|
||||
Do not implement this plan as:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION = void 0;
|
||||
exports.buildAssistantMcpDiscoveryAnswerDraft = buildAssistantMcpDiscoveryAnswerDraft;
|
||||
exports.ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION = "assistant_mcp_discovery_answer_draft_v1";
|
||||
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 isInternalMechanicsLine(value) {
|
||||
const text = value.toLowerCase();
|
||||
return (text.includes("primitive") ||
|
||||
text.includes("query_documents") ||
|
||||
text.includes("query_movements") ||
|
||||
text.includes("resolve_entity_reference") ||
|
||||
text.includes("probe_coverage") ||
|
||||
text.includes("explain_evidence_basis") ||
|
||||
text.includes("pilot_only_executes") ||
|
||||
text.includes("runtime_") ||
|
||||
text.includes("planner_") ||
|
||||
text.includes("catalog_"));
|
||||
}
|
||||
function userFacingLimitations(values) {
|
||||
return uniqueStrings(values).filter((value) => !isInternalMechanicsLine(value));
|
||||
}
|
||||
function modeFor(pilot) {
|
||||
if (pilot.pilot_status === "blocked") {
|
||||
return "blocked";
|
||||
}
|
||||
if (pilot.pilot_status === "skipped_needs_clarification") {
|
||||
return "needs_clarification";
|
||||
}
|
||||
if (pilot.evidence.answer_permission === "confirmed_answer") {
|
||||
return "confirmed_with_bounded_inference";
|
||||
}
|
||||
if (pilot.evidence.answer_permission === "bounded_inference") {
|
||||
return "bounded_inference_only";
|
||||
}
|
||||
return "checked_sources_only";
|
||||
}
|
||||
function headlineFor(mode) {
|
||||
if (mode === "confirmed_with_bounded_inference") {
|
||||
return "По данным 1С есть подтвержденная активность; длительность можно оценивать только как вывод из этих строк.";
|
||||
}
|
||||
if (mode === "bounded_inference_only") {
|
||||
return "Точный факт не подтвержден, но есть ограниченная оценка по найденной активности в 1С.";
|
||||
}
|
||||
if (mode === "needs_clarification") {
|
||||
return "Нужно уточнить контекст перед поиском в 1С.";
|
||||
}
|
||||
if (mode === "blocked") {
|
||||
return "Поиск в 1С заблокирован runtime-политикой до выполнения.";
|
||||
}
|
||||
return "Я проверил доступный контур, но подтвержденного факта для ответа не получил.";
|
||||
}
|
||||
function nextStepFor(mode, pilot) {
|
||||
if (mode === "needs_clarification") {
|
||||
return "Уточните контрагента, период или организацию, и я смогу выполнить проверку по 1С.";
|
||||
}
|
||||
if (mode === "checked_sources_only" && pilot.query_limitations.length > 0) {
|
||||
return "Можно повторить проверку после восстановления MCP-доступа или сузить вопрос до конкретного контрагента/периода.";
|
||||
}
|
||||
if (mode === "blocked") {
|
||||
return "Нужно сначала снять policy/blocking причину, иначе данные 1С использовать нельзя.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function buildMustNotClaim(pilot) {
|
||||
const claims = [
|
||||
"Do not claim legal registration age unless a legal registration source is confirmed.",
|
||||
"Do not present inferred activity duration as a formally confirmed legal fact.",
|
||||
"Do not expose MCP primitive names, query text, debug ids, or internal execution mechanics in the user answer.",
|
||||
"Do not claim rows were checked when mcp_execution_performed=false."
|
||||
];
|
||||
if (pilot.evidence.confirmed_facts.length === 0) {
|
||||
claims.push("Do not claim a confirmed business fact when confirmed_facts is empty.");
|
||||
}
|
||||
return claims;
|
||||
}
|
||||
function buildAssistantMcpDiscoveryAnswerDraft(pilot) {
|
||||
const mode = modeFor(pilot);
|
||||
const reasonCodes = [...pilot.reason_codes, ...pilot.evidence.reason_codes];
|
||||
pushReason(reasonCodes, `answer_mode_${mode}`);
|
||||
if (pilot.evidence.unknown_facts.length > 0) {
|
||||
pushReason(reasonCodes, "answer_contains_unknown_fact_boundary");
|
||||
}
|
||||
if (pilot.evidence.inferred_facts.length > 0) {
|
||||
pushReason(reasonCodes, "answer_contains_bounded_inference");
|
||||
}
|
||||
return {
|
||||
schema_version: exports.ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION,
|
||||
policy_owner: "assistantMcpDiscoveryAnswerAdapter",
|
||||
answer_mode: mode,
|
||||
headline: headlineFor(mode),
|
||||
confirmed_lines: uniqueStrings(pilot.evidence.confirmed_facts),
|
||||
inference_lines: uniqueStrings(pilot.evidence.inferred_facts),
|
||||
unknown_lines: uniqueStrings(pilot.evidence.unknown_facts),
|
||||
limitation_lines: userFacingLimitations([...pilot.query_limitations, ...pilot.evidence.query_limitations]),
|
||||
next_step_line: nextStepFor(mode, pilot),
|
||||
internal_mechanics_allowed: false,
|
||||
must_not_claim: buildMustNotClaim(pilot),
|
||||
reason_codes: uniqueStrings(reasonCodes)
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
import type { AssistantMcpDiscoveryPilotExecutionContract } from "./assistantMcpDiscoveryPilotExecutor";
|
||||
|
||||
export const ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION =
|
||||
"assistant_mcp_discovery_answer_draft_v1" as const;
|
||||
|
||||
export type AssistantMcpDiscoveryAnswerMode =
|
||||
| "confirmed_with_bounded_inference"
|
||||
| "bounded_inference_only"
|
||||
| "checked_sources_only"
|
||||
| "needs_clarification"
|
||||
| "blocked";
|
||||
|
||||
export interface AssistantMcpDiscoveryAnswerDraftContract {
|
||||
schema_version: typeof ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION;
|
||||
policy_owner: "assistantMcpDiscoveryAnswerAdapter";
|
||||
answer_mode: AssistantMcpDiscoveryAnswerMode;
|
||||
headline: string;
|
||||
confirmed_lines: string[];
|
||||
inference_lines: string[];
|
||||
unknown_lines: string[];
|
||||
limitation_lines: string[];
|
||||
next_step_line: string | null;
|
||||
internal_mechanics_allowed: false;
|
||||
must_not_claim: string[];
|
||||
reason_codes: string[];
|
||||
}
|
||||
|
||||
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 isInternalMechanicsLine(value: string): boolean {
|
||||
const text = value.toLowerCase();
|
||||
return (
|
||||
text.includes("primitive") ||
|
||||
text.includes("query_documents") ||
|
||||
text.includes("query_movements") ||
|
||||
text.includes("resolve_entity_reference") ||
|
||||
text.includes("probe_coverage") ||
|
||||
text.includes("explain_evidence_basis") ||
|
||||
text.includes("pilot_only_executes") ||
|
||||
text.includes("runtime_") ||
|
||||
text.includes("planner_") ||
|
||||
text.includes("catalog_")
|
||||
);
|
||||
}
|
||||
|
||||
function userFacingLimitations(values: string[]): string[] {
|
||||
return uniqueStrings(values).filter((value) => !isInternalMechanicsLine(value));
|
||||
}
|
||||
|
||||
function modeFor(pilot: AssistantMcpDiscoveryPilotExecutionContract): AssistantMcpDiscoveryAnswerMode {
|
||||
if (pilot.pilot_status === "blocked") {
|
||||
return "blocked";
|
||||
}
|
||||
if (pilot.pilot_status === "skipped_needs_clarification") {
|
||||
return "needs_clarification";
|
||||
}
|
||||
if (pilot.evidence.answer_permission === "confirmed_answer") {
|
||||
return "confirmed_with_bounded_inference";
|
||||
}
|
||||
if (pilot.evidence.answer_permission === "bounded_inference") {
|
||||
return "bounded_inference_only";
|
||||
}
|
||||
return "checked_sources_only";
|
||||
}
|
||||
|
||||
function headlineFor(mode: AssistantMcpDiscoveryAnswerMode): string {
|
||||
if (mode === "confirmed_with_bounded_inference") {
|
||||
return "По данным 1С есть подтвержденная активность; длительность можно оценивать только как вывод из этих строк.";
|
||||
}
|
||||
if (mode === "bounded_inference_only") {
|
||||
return "Точный факт не подтвержден, но есть ограниченная оценка по найденной активности в 1С.";
|
||||
}
|
||||
if (mode === "needs_clarification") {
|
||||
return "Нужно уточнить контекст перед поиском в 1С.";
|
||||
}
|
||||
if (mode === "blocked") {
|
||||
return "Поиск в 1С заблокирован runtime-политикой до выполнения.";
|
||||
}
|
||||
return "Я проверил доступный контур, но подтвержденного факта для ответа не получил.";
|
||||
}
|
||||
|
||||
function nextStepFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null {
|
||||
if (mode === "needs_clarification") {
|
||||
return "Уточните контрагента, период или организацию, и я смогу выполнить проверку по 1С.";
|
||||
}
|
||||
if (mode === "checked_sources_only" && pilot.query_limitations.length > 0) {
|
||||
return "Можно повторить проверку после восстановления MCP-доступа или сузить вопрос до конкретного контрагента/периода.";
|
||||
}
|
||||
if (mode === "blocked") {
|
||||
return "Нужно сначала снять policy/blocking причину, иначе данные 1С использовать нельзя.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildMustNotClaim(pilot: AssistantMcpDiscoveryPilotExecutionContract): string[] {
|
||||
const claims = [
|
||||
"Do not claim legal registration age unless a legal registration source is confirmed.",
|
||||
"Do not present inferred activity duration as a formally confirmed legal fact.",
|
||||
"Do not expose MCP primitive names, query text, debug ids, or internal execution mechanics in the user answer.",
|
||||
"Do not claim rows were checked when mcp_execution_performed=false."
|
||||
];
|
||||
if (pilot.evidence.confirmed_facts.length === 0) {
|
||||
claims.push("Do not claim a confirmed business fact when confirmed_facts is empty.");
|
||||
}
|
||||
return claims;
|
||||
}
|
||||
|
||||
export function buildAssistantMcpDiscoveryAnswerDraft(
|
||||
pilot: AssistantMcpDiscoveryPilotExecutionContract
|
||||
): AssistantMcpDiscoveryAnswerDraftContract {
|
||||
const mode = modeFor(pilot);
|
||||
const reasonCodes = [...pilot.reason_codes, ...pilot.evidence.reason_codes];
|
||||
pushReason(reasonCodes, `answer_mode_${mode}`);
|
||||
if (pilot.evidence.unknown_facts.length > 0) {
|
||||
pushReason(reasonCodes, "answer_contains_unknown_fact_boundary");
|
||||
}
|
||||
if (pilot.evidence.inferred_facts.length > 0) {
|
||||
pushReason(reasonCodes, "answer_contains_bounded_inference");
|
||||
}
|
||||
|
||||
return {
|
||||
schema_version: ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION,
|
||||
policy_owner: "assistantMcpDiscoveryAnswerAdapter",
|
||||
answer_mode: mode,
|
||||
headline: headlineFor(mode),
|
||||
confirmed_lines: uniqueStrings(pilot.evidence.confirmed_facts),
|
||||
inference_lines: uniqueStrings(pilot.evidence.inferred_facts),
|
||||
unknown_lines: uniqueStrings(pilot.evidence.unknown_facts),
|
||||
limitation_lines: userFacingLimitations([...pilot.query_limitations, ...pilot.evidence.query_limitations]),
|
||||
next_step_line: nextStepFor(mode, pilot),
|
||||
internal_mechanics_allowed: false,
|
||||
must_not_claim: buildMustNotClaim(pilot),
|
||||
reason_codes: uniqueStrings(reasonCodes)
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildAssistantMcpDiscoveryAnswerDraft } from "../src/services/assistantMcpDiscoveryAnswerAdapter";
|
||||
import { executeAssistantMcpDiscoveryPilot } from "../src/services/assistantMcpDiscoveryPilotExecutor";
|
||||
import { planAssistantMcpDiscovery } from "../src/services/assistantMcpDiscoveryPlanner";
|
||||
|
||||
function buildDeps(rows: Array<Record<string, unknown>>, error: string | null = null) {
|
||||
return {
|
||||
executeAddressMcpQuery: vi.fn(async () => ({
|
||||
fetched_rows: rows.length,
|
||||
matched_rows: error ? 0 : rows.length,
|
||||
raw_rows: rows,
|
||||
rows: error ? [] : rows,
|
||||
error
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
describe("assistant MCP discovery answer adapter", () => {
|
||||
it("turns confirmed lifecycle evidence into a human-safe bounded answer draft", async () => {
|
||||
const planner = planAssistantMcpDiscovery({
|
||||
turnMeaning: {
|
||||
asked_domain_family: "counterparty_lifecycle",
|
||||
asked_action_family: "activity_duration",
|
||||
explicit_entity_candidates: ["SVK"]
|
||||
}
|
||||
});
|
||||
const pilot = await executeAssistantMcpDiscoveryPilot(
|
||||
planner,
|
||||
buildDeps([{ Период: "2020-01-15T00:00:00", Регистратор: "CP_CUSTOMER_ACTIVITY_FIRST", Контрагент: "SVK" }])
|
||||
);
|
||||
|
||||
const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot);
|
||||
|
||||
expect(draft.answer_mode).toBe("confirmed_with_bounded_inference");
|
||||
expect(draft.internal_mechanics_allowed).toBe(false);
|
||||
expect(draft.headline).toContain("подтвержденная активность");
|
||||
expect(draft.confirmed_lines[0]).toContain("SVK");
|
||||
expect(draft.inference_lines[0]).toContain("may be inferred");
|
||||
expect(draft.unknown_lines).toContain("Legal registration date is not proven by this MCP discovery pilot");
|
||||
expect(draft.must_not_claim).toContain("Do not present inferred activity duration as a formally confirmed legal fact.");
|
||||
expect(draft.reason_codes).toContain("answer_contains_unknown_fact_boundary");
|
||||
});
|
||||
|
||||
it("uses checked-sources mode when MCP failed and avoids confirmed facts", async () => {
|
||||
const planner = planAssistantMcpDiscovery({
|
||||
turnMeaning: {
|
||||
asked_domain_family: "counterparty_lifecycle",
|
||||
asked_action_family: "activity_duration",
|
||||
explicit_entity_candidates: ["SVK"]
|
||||
}
|
||||
});
|
||||
const pilot = await executeAssistantMcpDiscoveryPilot(planner, buildDeps([], "MCP fetch failed: timeout"));
|
||||
|
||||
const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot);
|
||||
|
||||
expect(draft.answer_mode).toBe("checked_sources_only");
|
||||
expect(draft.confirmed_lines).toEqual([]);
|
||||
expect(draft.limitation_lines).toContain("MCP fetch failed: timeout");
|
||||
expect(draft.next_step_line).toContain("MCP");
|
||||
expect(draft.must_not_claim).toContain("Do not claim a confirmed business fact when confirmed_facts is empty.");
|
||||
});
|
||||
|
||||
it("asks for clarification when discovery did not execute due to missing scope", async () => {
|
||||
const planner = planAssistantMcpDiscovery({
|
||||
turnMeaning: {
|
||||
asked_domain_family: "counterparty_value",
|
||||
asked_action_family: "turnover",
|
||||
explicit_entity_candidates: ["SVK"]
|
||||
}
|
||||
});
|
||||
const pilot = await executeAssistantMcpDiscoveryPilot(planner, buildDeps([]));
|
||||
|
||||
const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot);
|
||||
|
||||
expect(draft.answer_mode).toBe("needs_clarification");
|
||||
expect(draft.headline).toBe("Нужно уточнить контекст перед поиском в 1С.");
|
||||
expect(draft.next_step_line).toContain("Уточните контрагента");
|
||||
expect(draft.must_not_claim).toContain("Do not claim rows were checked when mcp_execution_performed=false.");
|
||||
});
|
||||
|
||||
it("does not leak primitive names or query text into user-facing lines", async () => {
|
||||
const planner = planAssistantMcpDiscovery({
|
||||
turnMeaning: {
|
||||
asked_domain_family: "counterparty_lifecycle",
|
||||
asked_action_family: "activity_duration",
|
||||
explicit_entity_candidates: ["SVK"]
|
||||
}
|
||||
});
|
||||
const pilot = await executeAssistantMcpDiscoveryPilot(
|
||||
planner,
|
||||
buildDeps([{ Период: "2020-01-15T00:00:00", Регистратор: "CP_CUSTOMER_ACTIVITY_FIRST", Контрагент: "SVK" }])
|
||||
);
|
||||
|
||||
const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot);
|
||||
const userFacing = [
|
||||
draft.headline,
|
||||
...draft.confirmed_lines,
|
||||
...draft.inference_lines,
|
||||
...draft.unknown_lines,
|
||||
...draft.limitation_lines,
|
||||
draft.next_step_line ?? ""
|
||||
].join("\n");
|
||||
|
||||
expect(userFacing).not.toContain("query_documents");
|
||||
expect(userFacing).not.toContain("SELECT");
|
||||
expect(userFacing).not.toContain("ВЫБРАТЬ");
|
||||
expect(userFacing).not.toContain("primitive");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue