Stage 2 завершён: problem-first ответы и follow-up continuity - ассистент переведён от entity-heavy логики к problem-first ответам с problem-unit слоем, удержанием контекста в follow-up и очисткой пользовательского ответа от сырых технических ссылок.
This commit is contained in:
@@ -8,6 +8,9 @@ import type {
|
||||
} from "../types/assistant";
|
||||
import type { RouteHintSummary } from "../types/normalizer";
|
||||
import type { AnswerStructureV11, EvidenceConfidence, EvidenceItem, EvidenceLimitationReasonCode } from "../types/stage1Contracts";
|
||||
import type { ProblemUnit, ProblemUnitSummary, ProblemUnitType } from "../types/stage2ProblemUnits";
|
||||
|
||||
type ProblemAnswerMode = "stage1_policy_v11" | "stage2_problem_centric_v1";
|
||||
|
||||
interface ComposeAnswerInput {
|
||||
userMessage: string;
|
||||
@@ -17,6 +20,7 @@ interface ComposeAnswerInput {
|
||||
coverageReport: RequirementCoverageReport;
|
||||
groundingCheck: AnswerGroundingCheck;
|
||||
enableAnswerPolicyV11?: boolean;
|
||||
enableProblemCentricAnswerV1?: boolean;
|
||||
}
|
||||
|
||||
interface ComposeAnswerOutput {
|
||||
@@ -24,6 +28,10 @@ interface ComposeAnswerOutput {
|
||||
fallback_type: AssistantFallbackType;
|
||||
reply_type: AssistantReplyType;
|
||||
answer_structure_v11?: AnswerStructureV11;
|
||||
problem_centric_answer_applied?: boolean;
|
||||
problem_units_used_count?: number;
|
||||
problem_answer_mode?: ProblemAnswerMode;
|
||||
problem_unit_ids_used?: string[];
|
||||
}
|
||||
|
||||
function fallbackFromSummary(routeSummary: RouteHintSummary | null): AssistantFallbackType {
|
||||
@@ -37,6 +45,84 @@ function uniqueStrings(values: string[], limit = 6): string[] {
|
||||
return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean))).slice(0, limit);
|
||||
}
|
||||
|
||||
const UUID_PATTERN = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
|
||||
const LONG_HEX_PATTERN = /\b[0-9a-f]{24,}\b/gi;
|
||||
const RAW_REF_BLOB_PATTERN = /\bevidence_source_ref_v1\|[^\s,;]+/gi;
|
||||
const RAW_REF_TOKEN_PATTERN = /\b(?:source_ref|canonical_ref|entity_id|fragment_id|guid|uuid)\b/gi;
|
||||
|
||||
function looksLikeMojibake(value: string): boolean {
|
||||
const text = String(value ?? "");
|
||||
if (!text.trim()) {
|
||||
return false;
|
||||
}
|
||||
if (/(?:Р.|С.){5,}/u.test(text)) {
|
||||
return true;
|
||||
}
|
||||
if (/[ЃѓЂђЌќЎў]/u.test(text)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function looksLikeTechnicalIdentifier(value: string): boolean {
|
||||
const text = String(value ?? "").trim();
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
if (UUID_PATTERN.test(text)) {
|
||||
UUID_PATTERN.lastIndex = 0;
|
||||
return true;
|
||||
}
|
||||
UUID_PATTERN.lastIndex = 0;
|
||||
if (LONG_HEX_PATTERN.test(text)) {
|
||||
LONG_HEX_PATTERN.lastIndex = 0;
|
||||
return true;
|
||||
}
|
||||
LONG_HEX_PATTERN.lastIndex = 0;
|
||||
return /(?:evidence_source_ref_v1\||cmp%3a|batch_refresh_then_store:|^cmp:)/i.test(text);
|
||||
}
|
||||
|
||||
function scrubRawTechnicalRefs(value: string): string {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
return raw
|
||||
.replace(RAW_REF_BLOB_PATTERN, "linked source")
|
||||
.replace(UUID_PATTERN, "[id]")
|
||||
.replace(LONG_HEX_PATTERN, "[id]")
|
||||
.replace(RAW_REF_TOKEN_PATTERN, "reference")
|
||||
.replace(/\(\s*\[id\]\s*\)/g, "")
|
||||
.replace(/\[\s*id\s*\](?:\s*,\s*\[\s*id\s*\])+/g, "[id]")
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function sanitizeUserFacingReply(value: string): string {
|
||||
return scrubRawTechnicalRefs(value)
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function sanitizeUserText(value: string): string | null {
|
||||
const normalized = scrubRawTechnicalRefs(String(value ?? "").replace(/\s+/g, " ").trim());
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (looksLikeMojibake(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function sanitizeUserLines(values: string[], limit = 6): string[] {
|
||||
const cleaned = values
|
||||
.map((item) => sanitizeUserText(item))
|
||||
.filter((item): item is string => Boolean(item));
|
||||
return uniqueStrings(cleaned, limit);
|
||||
}
|
||||
|
||||
function formatList(items: string[]): string {
|
||||
if (items.length === 0) {
|
||||
return "";
|
||||
@@ -44,15 +130,28 @@ function formatList(items: string[]): string {
|
||||
return items.map((item) => `- ${item}`).join("\n");
|
||||
}
|
||||
|
||||
function formatSafeItemLine(entity: unknown, sourceId: unknown, riskScore?: unknown): string {
|
||||
const entityLabel = sanitizeUserText(String(entity ?? "")) ?? "Record";
|
||||
const idRaw = String(sourceId ?? "").trim();
|
||||
const exposeId = idRaw.length > 0 && !looksLikeTechnicalIdentifier(idRaw);
|
||||
const subject = exposeId ? `${entityLabel} (${idRaw})` : entityLabel;
|
||||
if (riskScore !== undefined) {
|
||||
return `${subject} - risk ${String(riskScore)}.`;
|
||||
}
|
||||
return `${subject}.`;
|
||||
}
|
||||
|
||||
function extractTopFacts(results: UnifiedRetrievalResult[]): string[] {
|
||||
const lines: string[] = [];
|
||||
for (const result of results.filter((item) => item.status === "ok").slice(0, 3)) {
|
||||
if (result.result_type === "chain") {
|
||||
const top = result.items.slice(0, 3).map((item) => {
|
||||
const counterparty = String(item.counterparty_id ?? "не указан");
|
||||
const counterparty = String(item.counterparty_id ?? "").trim();
|
||||
const operations = String(item.operations_count ?? "0");
|
||||
const docs = String(item.document_refs_count ?? "0");
|
||||
return `Контрагент ${counterparty}: операций ${operations}, документов в связке ${docs}.`;
|
||||
const counterpartyLabel =
|
||||
counterparty.length > 0 && !looksLikeTechnicalIdentifier(counterparty) ? `Counterparty ${counterparty}` : "Counterparty";
|
||||
return `${counterpartyLabel}: operations ${operations}, linked docs ${docs}.`;
|
||||
});
|
||||
lines.push(...top);
|
||||
continue;
|
||||
@@ -60,46 +159,46 @@ function extractTopFacts(results: UnifiedRetrievalResult[]): string[] {
|
||||
if (result.result_type === "ranking") {
|
||||
const top = result.items
|
||||
.slice(0, 5)
|
||||
.map((item) => `${item.rank ?? "•"}. ${String(item.entity ?? "Сущность")} — ${String(item.records_count ?? 0)}.`);
|
||||
.map((item) => `${item.rank ?? "*"}. ${String(item.entity ?? "Entity")} - ${String(item.records_count ?? 0)}.`);
|
||||
lines.push(...top);
|
||||
continue;
|
||||
}
|
||||
if (result.result_type === "list") {
|
||||
const top = result.items.slice(0, 5).map((item) => {
|
||||
if (item.risk_score !== undefined) {
|
||||
return `${String(item.source_entity ?? "Запись")} (${String(item.source_id ?? "")}) — риск ${String(item.risk_score)}.`;
|
||||
return formatSafeItemLine(item.source_entity ?? "Record", item.source_id ?? "", item.risk_score);
|
||||
}
|
||||
return `${String(item.source_entity ?? "Запись")} (${String(item.source_id ?? "")}).`;
|
||||
return formatSafeItemLine(item.source_entity ?? "Record", item.source_id ?? "");
|
||||
});
|
||||
lines.push(...top);
|
||||
continue;
|
||||
}
|
||||
const top = result.items
|
||||
.slice(0, 3)
|
||||
.map((item) => `${String(item.source_entity ?? "Запись")} (${String(item.source_id ?? "")}).`);
|
||||
.map((item) => formatSafeItemLine(item.source_entity ?? "Record", item.source_id ?? ""));
|
||||
lines.push(...top);
|
||||
}
|
||||
return lines;
|
||||
return sanitizeUserLines(lines, 8);
|
||||
}
|
||||
|
||||
function extractWhyIncluded(results: UnifiedRetrievalResult[]): string[] {
|
||||
return uniqueStrings(results.flatMap((item) => item.why_included));
|
||||
return sanitizeUserLines(results.flatMap((item) => item.why_included));
|
||||
}
|
||||
|
||||
function extractSelectionReasons(results: UnifiedRetrievalResult[]): string[] {
|
||||
return uniqueStrings(results.flatMap((item) => item.selection_reason));
|
||||
return sanitizeUserLines(results.flatMap((item) => item.selection_reason));
|
||||
}
|
||||
|
||||
function extractRiskFactors(results: UnifiedRetrievalResult[]): string[] {
|
||||
return uniqueStrings(results.flatMap((item) => item.risk_factors));
|
||||
return sanitizeUserLines(results.flatMap((item) => item.risk_factors));
|
||||
}
|
||||
|
||||
function extractBusinessInterpretation(results: UnifiedRetrievalResult[]): string[] {
|
||||
return uniqueStrings(results.flatMap((item) => item.business_interpretation));
|
||||
return sanitizeUserLines(results.flatMap((item) => item.business_interpretation));
|
||||
}
|
||||
|
||||
function extractLimitations(results: UnifiedRetrievalResult[]): string[] {
|
||||
return uniqueStrings(results.flatMap((item) => item.limitations));
|
||||
return sanitizeUserLines(results.flatMap((item) => item.limitations), 10);
|
||||
}
|
||||
|
||||
function summaryValue(result: UnifiedRetrievalResult, key: string): unknown {
|
||||
@@ -116,6 +215,63 @@ function summaryString(result: UnifiedRetrievalResult, key: string): string | nu
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function summaryNumber(result: UnifiedRetrievalResult, key: string): number | null {
|
||||
const value = summaryValue(result, key);
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function summaryStringArray(result: UnifiedRetrievalResult, key: string): string[] {
|
||||
const value = summaryValue(result, key);
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return sanitizeUserLines(value.map((item) => String(item)), 6);
|
||||
}
|
||||
|
||||
function buildFallbackWhyIncluded(results: UnifiedRetrievalResult[]): string[] {
|
||||
const lines: string[] = [];
|
||||
for (const result of results.slice(0, 2)) {
|
||||
const routeFocus = summaryString(result, "route_focus");
|
||||
const sourceRecords = summaryNumber(result, "source_records");
|
||||
const filteredRecords = summaryNumber(result, "filtered_records_after_narrowing");
|
||||
const checkedRecords = summaryNumber(result, "checked_records");
|
||||
|
||||
if (routeFocus) {
|
||||
lines.push(`Проверка выполнена по профилю ${routeFocus}.`);
|
||||
}
|
||||
if (sourceRecords !== null && filteredRecords !== null && filteredRecords < sourceRecords) {
|
||||
lines.push(`Применено сужение выборки: ${filteredRecords} из ${sourceRecords} записей.`);
|
||||
}
|
||||
if (checkedRecords !== null) {
|
||||
lines.push(`Проверено записей в текущем проходе: ${checkedRecords}.`);
|
||||
}
|
||||
}
|
||||
|
||||
return sanitizeUserLines(lines, 4);
|
||||
}
|
||||
|
||||
function buildFallbackSelectionReasons(results: UnifiedRetrievalResult[]): string[] {
|
||||
const lines: string[] = [];
|
||||
for (const result of results.slice(0, 2)) {
|
||||
if (summaryBoolean(result, "semantic_narrowing_applied")) {
|
||||
lines.push("Отбор выполнен по семантическому сужению предметной области.");
|
||||
}
|
||||
const rankingBasis = summaryStringArray(result, "ranking_basis");
|
||||
if (rankingBasis.length > 0) {
|
||||
lines.push(`Ранжирование основано на: ${rankingBasis.join(", ")}.`);
|
||||
}
|
||||
if (summaryBoolean(result, "broad_guard_applied")) {
|
||||
lines.push("Применен broad-query guard для контроля ложной точности.");
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
lines.push("Отбор выполнен по совпадению предметных сигналов и доступной evidence-опоры.");
|
||||
}
|
||||
|
||||
return sanitizeUserLines(lines, 4);
|
||||
}
|
||||
|
||||
function suggestNextStep(requirements: AssistantRequirement[], coverage: RequirementCoverageReport): string[] {
|
||||
const next: string[] = [];
|
||||
if (coverage.clarification_needed_for.length > 0) {
|
||||
@@ -165,10 +321,155 @@ interface MissingAnchors {
|
||||
anomalyType: boolean;
|
||||
}
|
||||
|
||||
const PROBLEM_HEAVY_TYPES = new Set<ProblemUnitType>([
|
||||
"document_conflict",
|
||||
"broken_chain_segment",
|
||||
"lifecycle_anomaly_node",
|
||||
"unresolved_settlement_cluster",
|
||||
"period_risk_cluster",
|
||||
"cross_branch_inconsistency_cluster"
|
||||
]);
|
||||
|
||||
function flattenEvidence(results: UnifiedRetrievalResult[]): EvidenceItem[] {
|
||||
return results.flatMap((item) => item.evidence);
|
||||
}
|
||||
|
||||
function flattenProblemUnits(results: UnifiedRetrievalResult[]): ProblemUnit[] {
|
||||
const units: ProblemUnit[] = [];
|
||||
for (const result of results) {
|
||||
if (!Array.isArray(result.problem_units)) {
|
||||
continue;
|
||||
}
|
||||
units.push(...result.problem_units);
|
||||
}
|
||||
const byId = new Map<string, ProblemUnit>();
|
||||
for (const unit of units) {
|
||||
byId.set(unit.problem_unit_id, unit);
|
||||
}
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
||||
function selectProblemUnitSummary(results: UnifiedRetrievalResult[]): ProblemUnitSummary | null {
|
||||
let selected: ProblemUnitSummary | null = null;
|
||||
for (const result of results) {
|
||||
if (!result.problem_unit_summary) {
|
||||
continue;
|
||||
}
|
||||
if (!selected || result.problem_unit_summary.units_total > selected.units_total) {
|
||||
selected = result.problem_unit_summary;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function formatAffectedScope(unit: ProblemUnit): string {
|
||||
const scopeParts: string[] = [];
|
||||
if (unit.affected_accounts.length > 0) {
|
||||
scopeParts.push(`счета: ${unit.affected_accounts.slice(0, 2).join(", ")}`);
|
||||
}
|
||||
if (unit.affected_counterparties.length > 0) {
|
||||
scopeParts.push(`контрагенты: ${unit.affected_counterparties.slice(0, 2).join(", ")}`);
|
||||
}
|
||||
if (unit.affected_documents.length > 0) {
|
||||
scopeParts.push(`документы: ${unit.affected_documents.slice(0, 2).join(", ")}`);
|
||||
}
|
||||
if (scopeParts.length === 0 && unit.affected_entities.length > 0) {
|
||||
scopeParts.push(`объекты: ${unit.affected_entities.slice(0, 2).join(", ")}`);
|
||||
}
|
||||
if (scopeParts.length === 0) {
|
||||
return "затронутый контур требует уточнения";
|
||||
}
|
||||
return scopeParts.join("; ");
|
||||
}
|
||||
|
||||
function buildProblemCentricActions(input: {
|
||||
units: ProblemUnit[];
|
||||
mode: PolicyMode;
|
||||
missingAnchors: MissingAnchors;
|
||||
coverageReport: RequirementCoverageReport;
|
||||
}): string[] {
|
||||
const actions: string[] = [];
|
||||
const unitTypes = new Set(input.units.map((item) => item.problem_unit_type));
|
||||
|
||||
if (unitTypes.has("broken_chain_segment")) {
|
||||
actions.push("Проверьте связку выписка -> документ -> проводка по проблемным участкам цепочки.");
|
||||
}
|
||||
if (unitTypes.has("unresolved_settlement_cluster")) {
|
||||
actions.push("Сверьте хвосты по расчетам: закрылся ли документ оплаты корректным закрывающим документом.");
|
||||
}
|
||||
if (unitTypes.has("period_risk_cluster")) {
|
||||
actions.push("Оцените влияние дефекта на закрытие периода и корректность регламентных операций.");
|
||||
}
|
||||
if (unitTypes.has("cross_branch_inconsistency_cluster")) {
|
||||
actions.push("Сверьте противоречия между документами, проводками и регистрами по НДС/межконтурным связям.");
|
||||
}
|
||||
if (unitTypes.has("lifecycle_anomaly_node")) {
|
||||
actions.push("Проверьте lifecycle объекта: ожидаемый этап не должен оставаться в partially_linked состоянии.");
|
||||
}
|
||||
|
||||
if (input.mode === "clarification_required") {
|
||||
if (input.missingAnchors.period) {
|
||||
actions.push("Уточните период проверки, чтобы зафиксировать границы проблемного контура.");
|
||||
}
|
||||
if (input.missingAnchors.account) {
|
||||
actions.push("Уточните счет или группу счетов для предметной локализации дефекта.");
|
||||
}
|
||||
if (input.missingAnchors.documentOrObject) {
|
||||
actions.push("Укажите конкретный документ или объект трассировки для проверки механизма отклонения.");
|
||||
}
|
||||
if (input.missingAnchors.counterparty) {
|
||||
actions.push("Укажите контрагента/договор, чтобы проверить хвосты и разрывы на конкретной связке.");
|
||||
}
|
||||
}
|
||||
|
||||
if (input.coverageReport.requirements_uncovered.length > 0) {
|
||||
actions.push(`Закройте непокрытые требования: ${input.coverageReport.requirements_uncovered.join(", ")}.`);
|
||||
}
|
||||
|
||||
return uniqueStrings(actions, 6);
|
||||
}
|
||||
|
||||
function buildProblemCentricClarifications(input: {
|
||||
units: ProblemUnit[];
|
||||
missingAnchors: MissingAnchors;
|
||||
coverageReport: RequirementCoverageReport;
|
||||
mode: PolicyMode;
|
||||
}): string[] {
|
||||
if (input.mode !== "clarification_required") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const questions: string[] = [];
|
||||
const unitTypes = new Set(input.units.map((item) => item.problem_unit_type));
|
||||
|
||||
if (input.missingAnchors.period) {
|
||||
questions.push("Уточните период (например, 2020-06), в котором нужно проверить проблемный кластер.");
|
||||
}
|
||||
if (input.missingAnchors.account) {
|
||||
questions.push("Уточните счет или связку счетов (например, 51/60), где вы ожидаете дефект.");
|
||||
}
|
||||
if (input.missingAnchors.documentOrObject) {
|
||||
questions.push("Укажите документ/объект, от которого нужно строить проверку цепочки.");
|
||||
}
|
||||
if (input.missingAnchors.counterparty) {
|
||||
questions.push("Укажите контрагента или договор, по которому проверить незакрытую экспозицию.");
|
||||
}
|
||||
if (unitTypes.has("broken_chain_segment")) {
|
||||
questions.push("Уточните участок цепочки: выписка, платежный документ или проводка.");
|
||||
}
|
||||
if (unitTypes.has("period_risk_cluster")) {
|
||||
questions.push("Уточните, какой этап закрытия периода критичен: начисление, закрытие счетов или НДС-блок.");
|
||||
}
|
||||
if (unitTypes.has("unresolved_settlement_cluster")) {
|
||||
questions.push("Уточните, интересуют хвосты поставщиков, покупателей или оба направления.");
|
||||
}
|
||||
if (input.coverageReport.clarification_needed_for.length > 0) {
|
||||
questions.push(`Закройте уточнения для требований: ${input.coverageReport.clarification_needed_for.join(", ")}.`);
|
||||
}
|
||||
|
||||
return uniqueStrings(questions, 6);
|
||||
}
|
||||
|
||||
function buildClaimEvidenceLinks(results: UnifiedRetrievalResult[]): NonNullable<AnswerStructureV11["evidence_block"]["claim_evidence_links"]> {
|
||||
const byClaim = new Map<string, string[]>();
|
||||
for (const evidence of flattenEvidence(results)) {
|
||||
@@ -333,7 +634,7 @@ function buildRecommendedActions(input: {
|
||||
}): string[] {
|
||||
const actions: string[] = [];
|
||||
if (input.mode === "focused_grounded") {
|
||||
actions.push("Проверьте 1-2 ключевые записи по source_ref и зафиксируйте итог в рабочем файле проверки.");
|
||||
actions.push("Проверьте 1-2 ключевые записи в учетной базе и зафиксируйте итог в рабочем файле проверки.");
|
||||
}
|
||||
if (input.mode === "broad_partial") {
|
||||
actions.push("Сузьте запрос до периода + счета или периода + документа и повторите проверку.");
|
||||
@@ -357,7 +658,7 @@ function buildRecommendedActions(input: {
|
||||
actions.push("Проверьте source mapping для связей document/register по указанным ref.");
|
||||
}
|
||||
if (input.sourceRefs.length > 0) {
|
||||
actions.push(`Начните проверку с source_ref: ${input.sourceRefs.slice(0, 2).join(", ")}.`);
|
||||
actions.push(`Начните проверку с ${input.sourceRefs.length} подтвержденных записей и сверьте их с первичными документами.`);
|
||||
}
|
||||
|
||||
return uniqueStrings(actions, 6);
|
||||
@@ -520,6 +821,167 @@ function buildDirectAnswer(input: {
|
||||
return "Не удалось сформировать обоснованный ответ; нужно уточнение запроса.";
|
||||
}
|
||||
|
||||
function buildProblemCentricAnswerSummary(input: {
|
||||
mode: PolicyMode;
|
||||
weakUnits: boolean;
|
||||
summary: ProblemUnitSummary | null;
|
||||
}): string {
|
||||
if (input.mode === "clarification_required") {
|
||||
return "Выявлены проблемные кластеры, но для надежного вывода требуется предметное уточнение фокуса.";
|
||||
}
|
||||
if (input.weakUnits) {
|
||||
return "Сформирован problem-centric срез с ограниченной опорой; вывод предварительный и требует до-проверки.";
|
||||
}
|
||||
if (input.summary?.units_total && input.summary.units_total > 1) {
|
||||
return `Сформирован problem-centric срез: выделено ${input.summary.units_total} проблемных кластера с приоритетами.`;
|
||||
}
|
||||
return "Сформирован problem-centric срез: выделен ключевой проблемный кластер и затронутый контур.";
|
||||
}
|
||||
|
||||
function buildProblemCentricDirectAnswer(input: {
|
||||
mode: PolicyMode;
|
||||
units: ProblemUnit[];
|
||||
weakUnits: boolean;
|
||||
}): string {
|
||||
const lead =
|
||||
input.mode === "clarification_required"
|
||||
? "Обнаружены проблемные зоны, но без уточнения якорей сильный factual-вывод преждевременен."
|
||||
: input.weakUnits
|
||||
? "Выделены проблемные зоны с ограниченной надежностью; вывод дан в ограниченном режиме."
|
||||
: "Выделены ключевые проблемные зоны и их влияние на учетный контур.";
|
||||
|
||||
const unitLines = input.units.map((unit) => {
|
||||
const scope = formatAffectedScope(unit);
|
||||
return `- ${unit.title}: ${unit.business_defect_class}; ${scope}; severity=${unit.severity.grade}, confidence=${unit.confidence.grade}.`;
|
||||
});
|
||||
|
||||
if (unitLines.length === 0) {
|
||||
return `${lead}\nПроблемные кластеры не удалось детализировать в текущем срезе.`;
|
||||
}
|
||||
|
||||
return [lead, "Проблемные кластеры:", ...unitLines].join("\n");
|
||||
}
|
||||
|
||||
function buildProblemCentricAnswerStructure(input: {
|
||||
mode: PolicyMode;
|
||||
selectedUnits: ProblemUnit[];
|
||||
problemSummary: ProblemUnitSummary | null;
|
||||
evidenceItems: EvidenceItem[];
|
||||
claimEvidenceLinks: NonNullable<AnswerStructureV11["evidence_block"]["claim_evidence_links"]>;
|
||||
limitationReasonCodes: EvidenceLimitationReasonCode[];
|
||||
groundingCheck: AnswerGroundingCheck;
|
||||
retrievalResults: UnifiedRetrievalResult[];
|
||||
missingAnchors: MissingAnchors;
|
||||
coverageReport: RequirementCoverageReport;
|
||||
}): AnswerStructureV11 {
|
||||
const weakUnits = input.selectedUnits.every((item) => item.confidence.grade === "low");
|
||||
const unitMechanismNotes = uniqueStrings(
|
||||
input.selectedUnits
|
||||
.map((item) => item.mechanism_summary)
|
||||
.filter((item) => typeof item === "string" && item.trim().length > 0),
|
||||
6
|
||||
);
|
||||
const sourceRefs = uniqueStrings(
|
||||
input.evidenceItems
|
||||
.map((item) => item.source_ref?.canonical_ref)
|
||||
.filter((item): item is string => typeof item === "string" && item.trim().length > 0),
|
||||
6
|
||||
);
|
||||
const evidenceIds = uniqueStrings(input.evidenceItems.map((item) => item.evidence_id), 10);
|
||||
|
||||
const mechanismStatus: AnswerStructureV11["mechanism_block"]["status"] =
|
||||
unitMechanismNotes.length === 0
|
||||
? "unresolved"
|
||||
: weakUnits || input.limitationReasonCodes.includes("missing_mechanism")
|
||||
? "limited"
|
||||
: "grounded";
|
||||
|
||||
const problemSpecificLimitations: string[] = [];
|
||||
if (weakUnits) {
|
||||
problemSpecificLimitations.push("Problem units remain weak-confidence; conclusions are intentionally limited.");
|
||||
}
|
||||
if (input.problemSummary?.duplicate_collapses && input.problemSummary.duplicate_collapses > 0) {
|
||||
problemSpecificLimitations.push("Part of the problem signal was merged due to duplicate collapse.");
|
||||
}
|
||||
|
||||
const limitations = uniqueStrings(
|
||||
[
|
||||
...problemSpecificLimitations,
|
||||
...input.limitationReasonCodes.map((code) => limitationReasonToText(code)),
|
||||
...extractLimitations(input.retrievalResults),
|
||||
...input.groundingCheck.reasons
|
||||
],
|
||||
10
|
||||
);
|
||||
|
||||
const openUncertainties = uniqueStrings(
|
||||
[
|
||||
...input.groundingCheck.missing_requirements,
|
||||
...(input.mode === "clarification_required" && input.missingAnchors.period ? ["missing_anchor:period"] : []),
|
||||
...(input.mode === "clarification_required" && input.missingAnchors.account ? ["missing_anchor:account"] : []),
|
||||
...(input.mode === "clarification_required" && input.missingAnchors.documentOrObject
|
||||
? ["missing_anchor:document_or_object"]
|
||||
: []),
|
||||
...(input.mode === "clarification_required" && input.missingAnchors.counterparty ? ["missing_anchor:counterparty"] : [])
|
||||
],
|
||||
8
|
||||
);
|
||||
|
||||
return {
|
||||
schema_version: "answer_structure_v1_1",
|
||||
answer_summary: buildProblemCentricAnswerSummary({
|
||||
mode: input.mode,
|
||||
weakUnits,
|
||||
summary: input.problemSummary
|
||||
}),
|
||||
direct_answer: buildProblemCentricDirectAnswer({
|
||||
mode: input.mode,
|
||||
units: input.selectedUnits,
|
||||
weakUnits
|
||||
}),
|
||||
mechanism_block: {
|
||||
status: mechanismStatus,
|
||||
mechanism_notes: unitMechanismNotes,
|
||||
limitation_reason_codes: input.limitationReasonCodes
|
||||
},
|
||||
evidence_block: {
|
||||
evidence_ids: evidenceIds,
|
||||
source_refs: sourceRefs,
|
||||
mechanism_notes: unitMechanismNotes,
|
||||
coverage_note:
|
||||
input.coverageReport.requirements_total > 0 &&
|
||||
input.coverageReport.requirements_total === input.coverageReport.requirements_covered &&
|
||||
input.coverageReport.requirements_uncovered.length === 0 &&
|
||||
input.coverageReport.requirements_partially_covered.length === 0
|
||||
? "coverage_full_or_near_full"
|
||||
: "coverage_partial_or_limited",
|
||||
...(input.claimEvidenceLinks.length > 0
|
||||
? {
|
||||
claim_evidence_links: input.claimEvidenceLinks
|
||||
}
|
||||
: {})
|
||||
},
|
||||
uncertainty_block: {
|
||||
open_uncertainties: openUncertainties,
|
||||
limitations
|
||||
},
|
||||
next_step_block: {
|
||||
recommended_actions: buildProblemCentricActions({
|
||||
units: input.selectedUnits,
|
||||
mode: input.mode,
|
||||
missingAnchors: input.missingAnchors,
|
||||
coverageReport: input.coverageReport
|
||||
}),
|
||||
clarification_questions: buildProblemCentricClarifications({
|
||||
units: input.selectedUnits,
|
||||
missingAnchors: input.missingAnchors,
|
||||
coverageReport: input.coverageReport,
|
||||
mode: input.mode
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function renderPolicyReply(structure: AnswerStructureV11): string {
|
||||
const mechanismLines: string[] = [`status=${structure.mechanism_block.status}`];
|
||||
if (structure.mechanism_block.mechanism_notes.length > 0) {
|
||||
@@ -532,18 +994,18 @@ function renderPolicyReply(structure: AnswerStructureV11): string {
|
||||
mechanismLines.push("mechanism_note is intentionally omitted due to weak or missing mechanism evidence");
|
||||
}
|
||||
|
||||
const sourceRefCount = Array.isArray(structure.evidence_block.source_refs) ? structure.evidence_block.source_refs.length : 0;
|
||||
const claimLinkCount = Array.isArray(structure.evidence_block.claim_evidence_links)
|
||||
? structure.evidence_block.claim_evidence_links.length
|
||||
: 0;
|
||||
const evidenceLines: string[] = [
|
||||
`coverage=${structure.evidence_block.coverage_note}`,
|
||||
`evidence_ids=${structure.evidence_block.evidence_ids.length > 0 ? structure.evidence_block.evidence_ids.join(", ") : "none"}`
|
||||
`supporting_evidence_count=${structure.evidence_block.evidence_ids.length}`,
|
||||
`supporting_source_count=${sourceRefCount}`,
|
||||
`claim_support_links=${claimLinkCount}`
|
||||
];
|
||||
if (Array.isArray(structure.evidence_block.source_refs) && structure.evidence_block.source_refs.length > 0) {
|
||||
evidenceLines.push(`source_refs=${structure.evidence_block.source_refs.join(", ")}`);
|
||||
}
|
||||
if (Array.isArray(structure.evidence_block.claim_evidence_links) && structure.evidence_block.claim_evidence_links.length > 0) {
|
||||
const compactLinks = structure.evidence_block.claim_evidence_links
|
||||
.slice(0, 4)
|
||||
.map((item) => `${item.claim_ref}:${item.evidence_ids.join("|")}`);
|
||||
evidenceLines.push(`claim_evidence_links=${compactLinks.join("; ")}`);
|
||||
if (sourceRefCount > 0) {
|
||||
evidenceLines.push("Detailed source references are available in debug payload.");
|
||||
}
|
||||
|
||||
const uncertaintyLines = [
|
||||
@@ -562,16 +1024,18 @@ function renderPolicyReply(structure: AnswerStructureV11): string {
|
||||
nextStepLines.push("No additional action is required for this scoped answer.");
|
||||
}
|
||||
|
||||
return [
|
||||
`Answer summary: ${structure.answer_summary}`,
|
||||
`Direct answer:\n${structure.direct_answer}`,
|
||||
`Mechanism block:\n${formatList(mechanismLines)}`,
|
||||
`Evidence block:\n${formatList(evidenceLines)}`,
|
||||
`Uncertainty block:\n${formatList(uncertaintyLines)}`,
|
||||
`Next step block:\n${formatList(nextStepLines)}`
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
return sanitizeUserFacingReply(
|
||||
[
|
||||
`Answer summary: ${structure.answer_summary}`,
|
||||
`Direct answer:\n${structure.direct_answer}`,
|
||||
`Mechanism block:\n${formatList(mechanismLines)}`,
|
||||
`Evidence block:\n${formatList(evidenceLines)}`,
|
||||
`Uncertainty block:\n${formatList(uncertaintyLines)}`,
|
||||
`Next step block:\n${formatList(nextStepLines)}`
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
);
|
||||
}
|
||||
|
||||
function composeAssistantAnswerV11(input: ComposeAnswerInput): ComposeAnswerOutput {
|
||||
@@ -595,8 +1059,15 @@ function composeAssistantAnswerV11(input: ComposeAnswerInput): ComposeAnswerOutp
|
||||
.filter((item): item is string => typeof item === "string" && item.trim().length > 0),
|
||||
6
|
||||
);
|
||||
const problemUnits = flattenProblemUnits(input.retrievalResults);
|
||||
const problemUnitSummary = selectProblemUnitSummary(input.retrievalResults);
|
||||
const problemHeavyUnits = problemUnits.filter((item) => PROBLEM_HEAVY_TYPES.has(item.problem_unit_type));
|
||||
const selectedProblemUnits = problemHeavyUnits.slice(0, 4);
|
||||
const claimEvidenceLinks = buildClaimEvidenceLinks(input.retrievalResults);
|
||||
const aggregateEvidenceConfidence = aggregateConfidence(input.retrievalResults, evidenceItems);
|
||||
const lowConfidenceSignals = evidenceItems.filter((item) => item.confidence === "low").length;
|
||||
const lowConfidenceShare = evidenceItems.length > 0 ? lowConfidenceSignals / evidenceItems.length : 0;
|
||||
const lowConfidenceConcentration = lowConfidenceShare >= 0.6;
|
||||
const hasSupport =
|
||||
okResults.length > 0 ||
|
||||
partialResults.length > 0 ||
|
||||
@@ -637,6 +1108,51 @@ function composeAssistantAnswerV11(input: ComposeAnswerInput): ComposeAnswerOutp
|
||||
});
|
||||
|
||||
const missingAnchors = detectMissingAnchors(input.userMessage);
|
||||
const hasProblemWeakSignal =
|
||||
policySignals.narrowing_strength !== "strong" ||
|
||||
policySignals.minimum_evidence_failed ||
|
||||
limitationReasonCodes.includes("missing_mechanism") ||
|
||||
limitationReasonCodes.includes("weak_source_mapping") ||
|
||||
aggregateEvidenceConfidence === "low" ||
|
||||
lowConfidenceConcentration;
|
||||
const hardBlockedMode = decision.mode === "out_of_scope" || decision.mode === "route_mismatch" || decision.mode === "backend_error";
|
||||
const problemCentricModeEligible =
|
||||
decision.mode === "broad_partial" ||
|
||||
decision.mode === "clarification_required" ||
|
||||
(decision.mode === "focused_grounded" && hasProblemWeakSignal);
|
||||
const shouldUseProblemCentricAnswer =
|
||||
Boolean(input.enableProblemCentricAnswerV1) &&
|
||||
!hardBlockedMode &&
|
||||
problemCentricModeEligible &&
|
||||
(!focusedStrong || hasProblemWeakSignal) &&
|
||||
selectedProblemUnits.length > 0;
|
||||
|
||||
if (shouldUseProblemCentricAnswer) {
|
||||
const problemCentricStructure = buildProblemCentricAnswerStructure({
|
||||
mode: decision.mode,
|
||||
selectedUnits: selectedProblemUnits,
|
||||
problemSummary: problemUnitSummary,
|
||||
evidenceItems,
|
||||
claimEvidenceLinks,
|
||||
limitationReasonCodes,
|
||||
groundingCheck: input.groundingCheck,
|
||||
retrievalResults: input.retrievalResults,
|
||||
missingAnchors,
|
||||
coverageReport: input.coverageReport
|
||||
});
|
||||
|
||||
return {
|
||||
assistant_reply: renderPolicyReply(problemCentricStructure),
|
||||
fallback_type: decision.fallback_type,
|
||||
reply_type: decision.reply_type,
|
||||
answer_structure_v11: problemCentricStructure,
|
||||
problem_centric_answer_applied: true,
|
||||
problem_units_used_count: selectedProblemUnits.length,
|
||||
problem_answer_mode: "stage2_problem_centric_v1",
|
||||
problem_unit_ids_used: selectedProblemUnits.map((item) => item.problem_unit_id)
|
||||
};
|
||||
}
|
||||
|
||||
const clarificationQuestions = buildClarificationQuestions({
|
||||
mode: decision.mode,
|
||||
missingAnchors,
|
||||
@@ -725,14 +1241,20 @@ function composeAssistantAnswerV11(input: ComposeAnswerInput): ComposeAnswerOutp
|
||||
assistant_reply: renderPolicyReply(answerStructure),
|
||||
fallback_type: decision.fallback_type,
|
||||
reply_type: decision.reply_type,
|
||||
answer_structure_v11: answerStructure
|
||||
answer_structure_v11: answerStructure,
|
||||
problem_centric_answer_applied: false,
|
||||
problem_units_used_count: 0,
|
||||
problem_answer_mode: "stage1_policy_v11"
|
||||
};
|
||||
}
|
||||
|
||||
function composeExplainableAnswer(input: ComposeAnswerInput, scopeLabel: "full" | "partial"): string {
|
||||
const facts = extractTopFacts(input.retrievalResults);
|
||||
const whyIncluded = extractWhyIncluded(input.retrievalResults);
|
||||
const selectionReasons = extractSelectionReasons(input.retrievalResults);
|
||||
const whyIncludedRaw = extractWhyIncluded(input.retrievalResults);
|
||||
const selectionReasonsRaw = extractSelectionReasons(input.retrievalResults);
|
||||
const whyIncluded = whyIncludedRaw.length > 0 ? whyIncludedRaw : buildFallbackWhyIncluded(input.retrievalResults);
|
||||
const selectionReasons =
|
||||
selectionReasonsRaw.length > 0 ? selectionReasonsRaw : buildFallbackSelectionReasons(input.retrievalResults);
|
||||
const riskFactors = extractRiskFactors(input.retrievalResults);
|
||||
const interpretation = extractBusinessInterpretation(input.retrievalResults);
|
||||
const limitations = uniqueStrings([...extractLimitations(input.retrievalResults), ...input.groundingCheck.reasons]);
|
||||
@@ -877,3 +1399,4 @@ export function composeAssistantAnswer(input: ComposeAnswerInput): ComposeAnswer
|
||||
reply_type: "backend_error"
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user