ARCH: добавить вывод длительности активности MCP discovery

This commit is contained in:
dctouch 2026-04-20 12:35:29 +03:00
parent 72804557aa
commit e85d456576
8 changed files with 293 additions and 3 deletions

View File

@ -1045,6 +1045,52 @@ Known next quality gap:
- the guarded candidate is now honest and safe, but it still does not compute and verbalize the exact activity duration such as "5 years N months" from first/latest confirmed rows. That belongs to the next evidence-derivation slice, not to the response gate itself.
## Progress Update - 2026-04-20 MCP Discovery Activity Duration Derivation
The fifteenth implementation slice of Big Block 5 closed the next quality gap in the guarded discovery answer:
- `assistantMcpDiscoveryPilotExecutor.ts`
- `assistantMcpDiscoveryAnswerAdapter.ts`
- `assistantMcpDiscoveryPilotExecutor.test.ts`
- `assistantMcpDiscoveryAnswerAdapter.test.ts`
- strengthened `address_truth_harness_phase19_mcp_discovery_response_gate.json`
The pilot now extracts a structured `derived_activity_period` from confirmed lifecycle rows:
- `first_activity_date`;
- `latest_activity_date`;
- `matched_rows`;
- `duration_total_months`;
- `duration_years`;
- `duration_months_remainder`;
- `duration_human_ru`;
- `inference_basis=first_and_latest_confirmed_1c_activity_rows`.
The answer adapter now verbalizes this as a user-facing bounded inference.
Example from the successful replay:
- first activity: `2014-10-06`;
- latest activity: `2022-04-13`;
- derived duration: `7 лет 6 месяцев`;
- answer wording: "По подтвержденным строкам активности в 1С период взаимодействия можно оценить примерно как 7 лет 6 месяцев... Это вывод по данным 1С, а не юридически подтвержденный возраст регистрации."
The generic inference sentence is suppressed when the structured duration line exists, so the final answer stays compact instead of repeating the same idea twice.
Replay result:
- `address_truth_harness_phase19_mcp_discovery_response_gate_live_rerun2` passed 4/4 after adding duration acceptance;
- `address_truth_harness_phase19_mcp_discovery_response_gate_live_rerun3` passed 4/4 after removing duplicate generic inference wording;
- final status stayed `accepted`;
- lifecycle answer source stayed `mcp_discovery_response_candidate_guarded`;
- debug confirmed `mcp_discovery_response_applied=true` and populated `derived_activity_period`.
Validation:
- `npm test -- assistantMcpDiscoveryPilotExecutor.test.ts assistantMcpDiscoveryAnswerAdapter.test.ts assistantMcpDiscoveryResponseCandidate.test.ts assistantMcpDiscoveryResponsePolicy.test.ts assistantLivingChatRuntimeAdapter.test.ts` passed 28/28;
- `npm run build` passed;
- `python scripts/domain_truth_harness.py run-live --spec docs/orchestration/address_truth_harness_phase19_mcp_discovery_response_gate.json --output-dir artifacts/domain_runs/address_truth_harness_phase19_mcp_discovery_response_gate_live_rerun3 --timeout-seconds 180` passed 4/4, final status `accepted`.
## Execution Rule
Do not implement this plan as:

View File

@ -61,6 +61,7 @@
"(?i)свк",
"(?i)1с|активност|подтвержд",
"(?i)вывод|оцен|инфер|можно оцен",
"(?i)лет|год|месяц",
"(?i)юридическ|регистрац|не подтвержд|не доказ"
],
"forbidden_answer_patterns": [

View File

@ -97,6 +97,17 @@ function buildMustNotClaim(pilot) {
}
return claims;
}
function derivedActivityInferenceLine(pilot) {
const period = pilot.derived_activity_period;
if (!period) {
return null;
}
return [
`По подтвержденным строкам активности в 1С период взаимодействия можно оценить примерно как ${period.duration_human_ru}.`,
`Первая найденная активность: ${period.first_activity_date}; последняя найденная активность: ${period.latest_activity_date}.`,
"Это вывод по данным 1С, а не юридически подтвержденный возраст регистрации."
].join(" ");
}
function buildAssistantMcpDiscoveryAnswerDraft(pilot) {
const mode = modeFor(pilot);
const reasonCodes = [...pilot.reason_codes, ...pilot.evidence.reason_codes];
@ -107,13 +118,17 @@ function buildAssistantMcpDiscoveryAnswerDraft(pilot) {
if (pilot.evidence.inferred_facts.length > 0) {
pushReason(reasonCodes, "answer_contains_bounded_inference");
}
const derivedInferenceLine = derivedActivityInferenceLine(pilot);
const inferenceLines = derivedInferenceLine
? [derivedInferenceLine]
: pilot.evidence.inferred_facts;
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),
inference_lines: uniqueStrings(inferenceLines),
unknown_lines: uniqueStrings(pilot.evidence.unknown_facts),
limitation_lines: userFacingLimitations([...pilot.query_limitations, ...pilot.evidence.query_limitations]),
next_step_line: nextStepFor(mode, pilot),

View File

@ -116,6 +116,74 @@ function summarizeRows(result) {
}
return `${result.fetched_rows} MCP document rows fetched, ${result.matched_rows} matched lifecycle scope`;
}
function rowDateValue(row) {
const candidates = [
row["Период"],
row["Period"],
row["period"],
row["Дата"],
row["Date"],
row["date"]
];
for (const candidate of candidates) {
const text = toNonEmptyString(candidate);
const match = text?.match(/(\d{4})-(\d{2})-(\d{2})/);
if (match) {
return `${match[1]}-${match[2]}-${match[3]}`;
}
}
return null;
}
function monthDiff(firstIsoDate, latestIsoDate) {
const first = new Date(`${firstIsoDate}T00:00:00.000Z`);
const latest = new Date(`${latestIsoDate}T00:00:00.000Z`);
if (Number.isNaN(first.getTime()) || Number.isNaN(latest.getTime()) || latest < first) {
return 0;
}
let months = (latest.getUTCFullYear() - first.getUTCFullYear()) * 12;
months += latest.getUTCMonth() - first.getUTCMonth();
if (latest.getUTCDate() < first.getUTCDate()) {
months -= 1;
}
return Math.max(0, months);
}
function formatDurationHumanRu(totalMonths) {
const years = Math.floor(totalMonths / 12);
const months = totalMonths % 12;
const parts = [];
if (years > 0) {
parts.push(`${years} ${years === 1 ? "год" : years >= 2 && years <= 4 ? "года" : "лет"}`);
}
if (months > 0) {
parts.push(`${months} ${months === 1 ? "месяц" : months >= 2 && months <= 4 ? "месяца" : "месяцев"}`);
}
return parts.length > 0 ? parts.join(" ") : "меньше месяца";
}
function deriveActivityPeriod(result) {
if (!result || result.error || result.matched_rows <= 0) {
return null;
}
const dates = result.rows
.map((row) => rowDateValue(row))
.filter((value) => Boolean(value))
.sort();
if (dates.length === 0) {
return null;
}
const first = dates[0];
const latest = dates[dates.length - 1];
const totalMonths = monthDiff(first, latest);
return {
first_activity_date: first,
latest_activity_date: latest,
matched_rows: result.matched_rows,
duration_total_months: totalMonths,
duration_years: Math.floor(totalMonths / 12),
duration_months_remainder: totalMonths % 12,
duration_human_ru: formatDurationHumanRu(totalMonths),
inference_basis: "first_and_latest_confirmed_1c_activity_rows"
};
}
function buildConfirmedFacts(result, counterparty) {
if (result.error || result.matched_rows <= 0) {
return [];
@ -166,6 +234,7 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) {
probe_results: probeResults,
evidence,
source_rows_summary: null,
derived_activity_period: null,
query_limitations: ["MCP discovery pilot was blocked before execution"],
reason_codes: reasonCodes
};
@ -185,6 +254,7 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) {
probe_results: probeResults,
evidence,
source_rows_summary: null,
derived_activity_period: null,
query_limitations: ["MCP discovery pilot needs more scope before execution"],
reason_codes: reasonCodes
};
@ -208,6 +278,7 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) {
probe_results: probeResults,
evidence,
source_rows_summary: null,
derived_activity_period: null,
query_limitations: ["MCP discovery pilot scope is not implemented yet"],
reason_codes: reasonCodes
};
@ -231,6 +302,7 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) {
probe_results: probeResults,
evidence,
source_rows_summary: null,
derived_activity_period: null,
query_limitations: ["Lifecycle recipe is not available"],
reason_codes: reasonCodes
};
@ -258,6 +330,10 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) {
}
}
const sourceRowsSummary = queryResult ? summarizeRows(queryResult) : null;
const derivedActivityPeriod = deriveActivityPeriod(queryResult);
if (derivedActivityPeriod) {
pushReason(reasonCodes, "pilot_derived_activity_period_from_confirmed_rows");
}
const evidence = (0, assistantMcpDiscoveryPolicy_1.resolveAssistantMcpDiscoveryEvidence)({
plan: planner.discovery_plan,
probeResults,
@ -280,6 +356,7 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) {
probe_results: probeResults,
evidence,
source_rows_summary: sourceRowsSummary,
derived_activity_period: derivedActivityPeriod,
query_limitations: queryLimitations,
reason_codes: reasonCodes
};

View File

@ -130,6 +130,18 @@ function buildMustNotClaim(pilot: AssistantMcpDiscoveryPilotExecutionContract):
return claims;
}
function derivedActivityInferenceLine(pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null {
const period = pilot.derived_activity_period;
if (!period) {
return null;
}
return [
`По подтвержденным строкам активности в 1С период взаимодействия можно оценить примерно как ${period.duration_human_ru}.`,
`Первая найденная активность: ${period.first_activity_date}; последняя найденная активность: ${period.latest_activity_date}.`,
"Это вывод по данным 1С, а не юридически подтвержденный возраст регистрации."
].join(" ");
}
export function buildAssistantMcpDiscoveryAnswerDraft(
pilot: AssistantMcpDiscoveryPilotExecutionContract
): AssistantMcpDiscoveryAnswerDraftContract {
@ -142,6 +154,10 @@ export function buildAssistantMcpDiscoveryAnswerDraft(
if (pilot.evidence.inferred_facts.length > 0) {
pushReason(reasonCodes, "answer_contains_bounded_inference");
}
const derivedInferenceLine = derivedActivityInferenceLine(pilot);
const inferenceLines = derivedInferenceLine
? [derivedInferenceLine]
: pilot.evidence.inferred_facts;
return {
schema_version: ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION,
@ -149,7 +165,7 @@ export function buildAssistantMcpDiscoveryAnswerDraft(
answer_mode: mode,
headline: headlineFor(mode),
confirmed_lines: uniqueStrings(pilot.evidence.confirmed_facts),
inference_lines: uniqueStrings(pilot.evidence.inferred_facts),
inference_lines: uniqueStrings(inferenceLines),
unknown_lines: uniqueStrings(pilot.evidence.unknown_facts),
limitation_lines: userFacingLimitations([...pilot.query_limitations, ...pilot.evidence.query_limitations]),
next_step_line: nextStepFor(mode, pilot),

View File

@ -29,6 +29,17 @@ export interface AssistantMcpDiscoveryPilotExecutorDeps {
executeAddressMcpQuery: typeof executeAddressMcpQuery;
}
export interface AssistantMcpDiscoveryDerivedActivityPeriod {
first_activity_date: string;
latest_activity_date: string;
matched_rows: number;
duration_total_months: number;
duration_years: number;
duration_months_remainder: number;
duration_human_ru: string;
inference_basis: "first_and_latest_confirmed_1c_activity_rows";
}
export interface AssistantMcpDiscoveryPilotExecutionContract {
schema_version: typeof ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION;
policy_owner: "assistantMcpDiscoveryPilotExecutor";
@ -41,6 +52,7 @@ export interface AssistantMcpDiscoveryPilotExecutionContract {
probe_results: AssistantMcpDiscoveryProbeResult[];
evidence: AssistantMcpDiscoveryEvidenceContract;
source_rows_summary: string | null;
derived_activity_period: AssistantMcpDiscoveryDerivedActivityPeriod | null;
query_limitations: string[];
reason_codes: string[];
}
@ -173,6 +185,80 @@ function summarizeRows(result: AddressMcpQueryExecutorResult): string | null {
return `${result.fetched_rows} MCP document rows fetched, ${result.matched_rows} matched lifecycle scope`;
}
function rowDateValue(row: Record<string, unknown>): string | null {
const candidates = [
row["Период"],
row["Period"],
row["period"],
row["Дата"],
row["Date"],
row["date"]
];
for (const candidate of candidates) {
const text = toNonEmptyString(candidate);
const match = text?.match(/(\d{4})-(\d{2})-(\d{2})/);
if (match) {
return `${match[1]}-${match[2]}-${match[3]}`;
}
}
return null;
}
function monthDiff(firstIsoDate: string, latestIsoDate: string): number {
const first = new Date(`${firstIsoDate}T00:00:00.000Z`);
const latest = new Date(`${latestIsoDate}T00:00:00.000Z`);
if (Number.isNaN(first.getTime()) || Number.isNaN(latest.getTime()) || latest < first) {
return 0;
}
let months = (latest.getUTCFullYear() - first.getUTCFullYear()) * 12;
months += latest.getUTCMonth() - first.getUTCMonth();
if (latest.getUTCDate() < first.getUTCDate()) {
months -= 1;
}
return Math.max(0, months);
}
function formatDurationHumanRu(totalMonths: number): string {
const years = Math.floor(totalMonths / 12);
const months = totalMonths % 12;
const parts: string[] = [];
if (years > 0) {
parts.push(`${years} ${years === 1 ? "год" : years >= 2 && years <= 4 ? "года" : "лет"}`);
}
if (months > 0) {
parts.push(`${months} ${months === 1 ? "месяц" : months >= 2 && months <= 4 ? "месяца" : "месяцев"}`);
}
return parts.length > 0 ? parts.join(" ") : "меньше месяца";
}
function deriveActivityPeriod(
result: AddressMcpQueryExecutorResult | null
): AssistantMcpDiscoveryDerivedActivityPeriod | null {
if (!result || result.error || result.matched_rows <= 0) {
return null;
}
const dates = result.rows
.map((row) => rowDateValue(row))
.filter((value): value is string => Boolean(value))
.sort();
if (dates.length === 0) {
return null;
}
const first = dates[0];
const latest = dates[dates.length - 1];
const totalMonths = monthDiff(first, latest);
return {
first_activity_date: first,
latest_activity_date: latest,
matched_rows: result.matched_rows,
duration_total_months: totalMonths,
duration_years: Math.floor(totalMonths / 12),
duration_months_remainder: totalMonths % 12,
duration_human_ru: formatDurationHumanRu(totalMonths),
inference_basis: "first_and_latest_confirmed_1c_activity_rows"
};
}
function buildConfirmedFacts(result: AddressMcpQueryExecutorResult, counterparty: string | null): string[] {
if (result.error || result.matched_rows <= 0) {
return [];
@ -236,6 +322,7 @@ export async function executeAssistantMcpDiscoveryPilot(
probe_results: probeResults,
evidence,
source_rows_summary: null,
derived_activity_period: null,
query_limitations: ["MCP discovery pilot was blocked before execution"],
reason_codes: reasonCodes
};
@ -256,6 +343,7 @@ export async function executeAssistantMcpDiscoveryPilot(
probe_results: probeResults,
evidence,
source_rows_summary: null,
derived_activity_period: null,
query_limitations: ["MCP discovery pilot needs more scope before execution"],
reason_codes: reasonCodes
};
@ -280,6 +368,7 @@ export async function executeAssistantMcpDiscoveryPilot(
probe_results: probeResults,
evidence,
source_rows_summary: null,
derived_activity_period: null,
query_limitations: ["MCP discovery pilot scope is not implemented yet"],
reason_codes: reasonCodes
};
@ -304,6 +393,7 @@ export async function executeAssistantMcpDiscoveryPilot(
probe_results: probeResults,
evidence,
source_rows_summary: null,
derived_activity_period: null,
query_limitations: ["Lifecycle recipe is not available"],
reason_codes: reasonCodes
};
@ -332,6 +422,10 @@ export async function executeAssistantMcpDiscoveryPilot(
}
const sourceRowsSummary = queryResult ? summarizeRows(queryResult) : null;
const derivedActivityPeriod = deriveActivityPeriod(queryResult);
if (derivedActivityPeriod) {
pushReason(reasonCodes, "pilot_derived_activity_period_from_confirmed_rows");
}
const evidence = resolveAssistantMcpDiscoveryEvidence({
plan: planner.discovery_plan,
probeResults,
@ -355,6 +449,7 @@ export async function executeAssistantMcpDiscoveryPilot(
probe_results: probeResults,
evidence,
source_rows_summary: sourceRowsSummary,
derived_activity_period: derivedActivityPeriod,
query_limitations: queryLimitations,
reason_codes: reasonCodes
};

View File

@ -35,7 +35,9 @@ describe("assistant MCP discovery answer adapter", () => {
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.inference_lines[0]).toContain("меньше месяца");
expect(draft.inference_lines.join("\n")).toContain("Первая найденная активность: 2020-01-15");
expect(draft.inference_lines.join("\n")).toContain("не юридически подтвержденный возраст регистрации");
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");
@ -106,4 +108,30 @@ describe("assistant MCP discovery answer adapter", () => {
expect(userFacing).not.toContain("ВЫБРАТЬ");
expect(userFacing).not.toContain("primitive");
});
it("verbalizes activity duration from first and latest confirmed 1C rows", 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([
{ Period: "2020-01-15T00:00:00", Counterparty: "SVK" },
{ Period: "2023-12-20T00:00:00", Counterparty: "SVK" }
])
);
const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot);
const inferenceText = draft.inference_lines.join("\n");
expect(inferenceText).toContain("3 года 11 месяцев");
expect(inferenceText).toContain("2020-01-15");
expect(inferenceText).toContain("2023-12-20");
expect(inferenceText).toContain("не юридически подтвержденный возраст регистрации");
expect(draft.reason_codes).toContain("pilot_derived_activity_period_from_confirmed_rows");
});
});

View File

@ -39,7 +39,18 @@ describe("assistant MCP discovery pilot executor", () => {
expect(result.evidence.inferred_facts[0]).toContain("may be inferred");
expect(result.evidence.unknown_facts).toContain("Legal registration date is not proven by this MCP discovery pilot");
expect(result.source_rows_summary).toBe("2 MCP document rows fetched, 2 matched lifecycle scope");
expect(result.derived_activity_period).toEqual({
first_activity_date: "2020-01-15",
latest_activity_date: "2023-12-20",
matched_rows: 2,
duration_total_months: 47,
duration_years: 3,
duration_months_remainder: 11,
duration_human_ru: "3 года 11 месяцев",
inference_basis: "first_and_latest_confirmed_1c_activity_rows"
});
expect(result.reason_codes).toContain("pilot_query_documents_mcp_executed");
expect(result.reason_codes).toContain("pilot_derived_activity_period_from_confirmed_rows");
expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(1);
const call = deps.executeAddressMcpQuery.mock.calls[0]?.[0];
@ -100,6 +111,7 @@ describe("assistant MCP discovery pilot executor", () => {
expect(result.mcp_execution_performed).toBe(true);
expect(result.evidence.evidence_status).toBe("insufficient");
expect(result.evidence.confirmed_facts).toEqual([]);
expect(result.derived_activity_period).toBeNull();
expect(result.query_limitations).toContain("MCP fetch failed: timeout");
expect(result.reason_codes).toContain("pilot_query_documents_mcp_error");
});