Initial import NDC_1C
This commit is contained in:
@@ -0,0 +1,879 @@
|
||||
import type {
|
||||
AssistantFallbackType,
|
||||
AssistantReplyType,
|
||||
AnswerGroundingCheck,
|
||||
AssistantRequirement,
|
||||
RequirementCoverageReport,
|
||||
UnifiedRetrievalResult
|
||||
} from "../types/assistant";
|
||||
import type { RouteHintSummary } from "../types/normalizer";
|
||||
import type { AnswerStructureV11, EvidenceConfidence, EvidenceItem, EvidenceLimitationReasonCode } from "../types/stage1Contracts";
|
||||
|
||||
interface ComposeAnswerInput {
|
||||
userMessage: string;
|
||||
routeSummary: RouteHintSummary | null;
|
||||
retrievalResults: UnifiedRetrievalResult[];
|
||||
requirements: AssistantRequirement[];
|
||||
coverageReport: RequirementCoverageReport;
|
||||
groundingCheck: AnswerGroundingCheck;
|
||||
enableAnswerPolicyV11?: boolean;
|
||||
}
|
||||
|
||||
interface ComposeAnswerOutput {
|
||||
assistant_reply: string;
|
||||
fallback_type: AssistantFallbackType;
|
||||
reply_type: AssistantReplyType;
|
||||
answer_structure_v11?: AnswerStructureV11;
|
||||
}
|
||||
|
||||
function fallbackFromSummary(routeSummary: RouteHintSummary | null): AssistantFallbackType {
|
||||
if (!routeSummary || routeSummary.mode !== "deterministic_v2") {
|
||||
return "none";
|
||||
}
|
||||
return routeSummary.fallback.type as AssistantFallbackType;
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[], limit = 6): string[] {
|
||||
return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean))).slice(0, limit);
|
||||
}
|
||||
|
||||
function formatList(items: string[]): string {
|
||||
if (items.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return items.map((item) => `- ${item}`).join("\n");
|
||||
}
|
||||
|
||||
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 operations = String(item.operations_count ?? "0");
|
||||
const docs = String(item.document_refs_count ?? "0");
|
||||
return `Контрагент ${counterparty}: операций ${operations}, документов в связке ${docs}.`;
|
||||
});
|
||||
lines.push(...top);
|
||||
continue;
|
||||
}
|
||||
if (result.result_type === "ranking") {
|
||||
const top = result.items
|
||||
.slice(0, 5)
|
||||
.map((item) => `${item.rank ?? "•"}. ${String(item.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 `${String(item.source_entity ?? "Запись")} (${String(item.source_id ?? "")}).`;
|
||||
});
|
||||
lines.push(...top);
|
||||
continue;
|
||||
}
|
||||
const top = result.items
|
||||
.slice(0, 3)
|
||||
.map((item) => `${String(item.source_entity ?? "Запись")} (${String(item.source_id ?? "")}).`);
|
||||
lines.push(...top);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function extractWhyIncluded(results: UnifiedRetrievalResult[]): string[] {
|
||||
return uniqueStrings(results.flatMap((item) => item.why_included));
|
||||
}
|
||||
|
||||
function extractSelectionReasons(results: UnifiedRetrievalResult[]): string[] {
|
||||
return uniqueStrings(results.flatMap((item) => item.selection_reason));
|
||||
}
|
||||
|
||||
function extractRiskFactors(results: UnifiedRetrievalResult[]): string[] {
|
||||
return uniqueStrings(results.flatMap((item) => item.risk_factors));
|
||||
}
|
||||
|
||||
function extractBusinessInterpretation(results: UnifiedRetrievalResult[]): string[] {
|
||||
return uniqueStrings(results.flatMap((item) => item.business_interpretation));
|
||||
}
|
||||
|
||||
function extractLimitations(results: UnifiedRetrievalResult[]): string[] {
|
||||
return uniqueStrings(results.flatMap((item) => item.limitations));
|
||||
}
|
||||
|
||||
function summaryValue(result: UnifiedRetrievalResult, key: string): unknown {
|
||||
const summary = result.summary ?? {};
|
||||
return Object.prototype.hasOwnProperty.call(summary, key) ? summary[key] : undefined;
|
||||
}
|
||||
|
||||
function summaryBoolean(result: UnifiedRetrievalResult, key: string): boolean {
|
||||
return summaryValue(result, key) === true;
|
||||
}
|
||||
|
||||
function summaryString(result: UnifiedRetrievalResult, key: string): string | null {
|
||||
const value = summaryValue(result, key);
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function suggestNextStep(requirements: AssistantRequirement[], coverage: RequirementCoverageReport): string[] {
|
||||
const next: string[] = [];
|
||||
if (coverage.clarification_needed_for.length > 0) {
|
||||
next.push("Уточните период, счет, документ или контрагента для требований: " + coverage.clarification_needed_for.join(", ") + ".");
|
||||
}
|
||||
if (coverage.requirements_uncovered.length > 0) {
|
||||
next.push("Проверьте непокрытые требования: " + coverage.requirements_uncovered.join(", ") + ".");
|
||||
}
|
||||
if (coverage.out_of_scope_requirements.length > 0) {
|
||||
next.push("Часть запроса вне текущего учетного контура: " + coverage.out_of_scope_requirements.join(", ") + ".");
|
||||
}
|
||||
if (next.length === 0 && requirements.length > 0) {
|
||||
next.push("Следующим шагом можно открыть технический разбор и углубить проверку по выбранным объектам.");
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
interface PolicySignals {
|
||||
broad_query_detected: boolean;
|
||||
broad_result_flag: boolean;
|
||||
minimum_evidence_failed: boolean;
|
||||
degraded_to: "partial" | "clarification" | null;
|
||||
narrowing_strength: "weak" | "medium" | "strong" | null;
|
||||
}
|
||||
|
||||
type PolicyMode =
|
||||
| "focused_grounded"
|
||||
| "broad_partial"
|
||||
| "clarification_required"
|
||||
| "out_of_scope"
|
||||
| "route_mismatch"
|
||||
| "empty"
|
||||
| "no_grounded"
|
||||
| "backend_error";
|
||||
|
||||
interface PolicyDecision {
|
||||
mode: PolicyMode;
|
||||
fallback_type: AssistantFallbackType;
|
||||
reply_type: AssistantReplyType;
|
||||
}
|
||||
|
||||
interface MissingAnchors {
|
||||
period: boolean;
|
||||
account: boolean;
|
||||
documentOrObject: boolean;
|
||||
counterparty: boolean;
|
||||
anomalyType: boolean;
|
||||
}
|
||||
|
||||
function flattenEvidence(results: UnifiedRetrievalResult[]): EvidenceItem[] {
|
||||
return results.flatMap((item) => item.evidence);
|
||||
}
|
||||
|
||||
function buildClaimEvidenceLinks(results: UnifiedRetrievalResult[]): NonNullable<AnswerStructureV11["evidence_block"]["claim_evidence_links"]> {
|
||||
const byClaim = new Map<string, string[]>();
|
||||
for (const evidence of flattenEvidence(results)) {
|
||||
const claimRef = String(evidence.claim_ref ?? "").trim();
|
||||
const evidenceId = String(evidence.evidence_id ?? "").trim();
|
||||
if (!claimRef || !evidenceId) {
|
||||
continue;
|
||||
}
|
||||
const current = byClaim.get(claimRef) ?? [];
|
||||
current.push(evidenceId);
|
||||
byClaim.set(claimRef, current);
|
||||
}
|
||||
return Array.from(byClaim.entries())
|
||||
.slice(0, 10)
|
||||
.map(([claim_ref, evidenceIds]) => ({
|
||||
claim_ref,
|
||||
evidence_ids: uniqueStrings(evidenceIds, 10)
|
||||
}));
|
||||
}
|
||||
|
||||
function aggregatePolicySignals(results: UnifiedRetrievalResult[]): PolicySignals {
|
||||
const broad_query_detected = results.some((item) => summaryBoolean(item, "broad_query_detected"));
|
||||
const broad_result_flag = results.some((item) => summaryBoolean(item, "broad_result_flag"));
|
||||
const minimum_evidence_failed = results.some((item) => summaryBoolean(item, "minimum_evidence_failed"));
|
||||
|
||||
let degraded_to: PolicySignals["degraded_to"] = null;
|
||||
for (const result of results) {
|
||||
const degraded = summaryString(result, "degraded_to");
|
||||
if (degraded === "clarification") {
|
||||
degraded_to = "clarification";
|
||||
break;
|
||||
}
|
||||
if (degraded === "partial") {
|
||||
degraded_to = "partial";
|
||||
}
|
||||
}
|
||||
|
||||
const narrowingOrder: Record<"weak" | "medium" | "strong", number> = {
|
||||
weak: 0,
|
||||
medium: 1,
|
||||
strong: 2
|
||||
};
|
||||
let narrowing_strength: PolicySignals["narrowing_strength"] = null;
|
||||
for (const result of results) {
|
||||
const value = summaryString(result, "narrowing_strength");
|
||||
if (value !== "weak" && value !== "medium" && value !== "strong") {
|
||||
continue;
|
||||
}
|
||||
if (!narrowing_strength || narrowingOrder[value] < narrowingOrder[narrowing_strength]) {
|
||||
narrowing_strength = value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
broad_query_detected,
|
||||
broad_result_flag,
|
||||
minimum_evidence_failed,
|
||||
degraded_to,
|
||||
narrowing_strength
|
||||
};
|
||||
}
|
||||
|
||||
function confidenceToScore(value: EvidenceConfidence): number {
|
||||
if (value === "high") return 3;
|
||||
if (value === "medium") return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function aggregateConfidence(results: UnifiedRetrievalResult[], evidenceItems: EvidenceItem[]): EvidenceConfidence {
|
||||
const scores: number[] = [];
|
||||
for (const evidence of evidenceItems) {
|
||||
scores.push(confidenceToScore(evidence.confidence));
|
||||
}
|
||||
for (const result of results) {
|
||||
if (result.status === "error") {
|
||||
continue;
|
||||
}
|
||||
scores.push(confidenceToScore(result.confidence));
|
||||
}
|
||||
if (scores.length === 0) {
|
||||
return "low";
|
||||
}
|
||||
const average = scores.reduce((acc, item) => acc + item, 0) / scores.length;
|
||||
if (average >= 2.6) return "high";
|
||||
if (average >= 1.8) return "medium";
|
||||
return "low";
|
||||
}
|
||||
|
||||
function collectLimitationReasonCodes(evidenceItems: EvidenceItem[]): EvidenceLimitationReasonCode[] {
|
||||
const codes = evidenceItems
|
||||
.map((item) => item.limitation?.reason_code ?? null)
|
||||
.filter((item): item is EvidenceLimitationReasonCode => Boolean(item));
|
||||
return uniqueStrings(codes, 8) as EvidenceLimitationReasonCode[];
|
||||
}
|
||||
|
||||
function limitationReasonToText(code: EvidenceLimitationReasonCode): string {
|
||||
if (code === "snapshot_only") return "Evidence is snapshot-only and may lag source-of-record.";
|
||||
if (code === "heuristic_inference") return "Part of the conclusion relies on heuristic inference.";
|
||||
if (code === "missing_mechanism") return "Mechanism is unresolved for part of the evidence.";
|
||||
if (code === "weak_source_mapping") return "Source mapping is weak for part of the evidence.";
|
||||
if (code === "insufficient_detail") return "Evidence lacks detail for a strong factual claim.";
|
||||
return "Some evidence limitations remain unresolved.";
|
||||
}
|
||||
|
||||
function detectMissingAnchors(userMessage: string): MissingAnchors {
|
||||
const lower = String(userMessage ?? "").toLowerCase();
|
||||
const hasPeriod = /\b20\d{2}(?:[-./](?:0[1-9]|1[0-2]))?\b/.test(lower);
|
||||
const hasAccount = /(?:\bсчет\b|\baccount\b|\bschet\b|\b\d{2}(?:\.\d{2})?\b)/i.test(lower);
|
||||
const hasDocumentOrObject = /(?:документ|invoice|guid|object|obj|#\d+|\bid\b|\bref\b|dokument|doc)/i.test(lower);
|
||||
const hasCounterparty = /(?:контрагент|supplier|buyer|customer|kontragent|postavsh|pokupatel)/i.test(lower);
|
||||
const hasAnomalyType = /(?:аномал|risk|отклон|разрыв|mismatch|duplicate|tail|цепочк|anomali|hvost)/i.test(lower);
|
||||
|
||||
return {
|
||||
period: !hasPeriod,
|
||||
account: !hasAccount,
|
||||
documentOrObject: !hasDocumentOrObject,
|
||||
counterparty: !hasCounterparty,
|
||||
anomalyType: !hasAnomalyType
|
||||
};
|
||||
}
|
||||
|
||||
function buildClarificationQuestions(input: {
|
||||
mode: PolicyMode;
|
||||
missingAnchors: MissingAnchors;
|
||||
coverageReport: RequirementCoverageReport;
|
||||
policySignals: PolicySignals;
|
||||
}): string[] {
|
||||
const questions: string[] = [];
|
||||
const shouldAsk = input.mode === "clarification_required" || input.coverageReport.clarification_needed_for.length > 0;
|
||||
if (!shouldAsk) {
|
||||
return questions;
|
||||
}
|
||||
|
||||
if (input.missingAnchors.period) {
|
||||
questions.push("Уточните период проверки (например, 2020-06).");
|
||||
}
|
||||
if (input.missingAnchors.account) {
|
||||
questions.push("Уточните счет или группу счетов (например, 19, 60, 62).");
|
||||
}
|
||||
if (input.missingAnchors.documentOrObject) {
|
||||
questions.push("Укажите документ/GUID/конкретный объект для трассировки.");
|
||||
}
|
||||
if (input.missingAnchors.counterparty) {
|
||||
questions.push("Укажите контрагента или группу контрагентов.");
|
||||
}
|
||||
if (input.policySignals.broad_query_detected && input.missingAnchors.anomalyType) {
|
||||
questions.push("Уточните тип отклонения: разрыв цепочки, неверный документ или аномальный риск.");
|
||||
}
|
||||
if (input.coverageReport.clarification_needed_for.length > 0) {
|
||||
questions.push(`Закройте уточнения для требований: ${input.coverageReport.clarification_needed_for.join(", ")}.`);
|
||||
}
|
||||
|
||||
return uniqueStrings(questions, 6);
|
||||
}
|
||||
|
||||
function buildRecommendedActions(input: {
|
||||
mode: PolicyMode;
|
||||
coverageReport: RequirementCoverageReport;
|
||||
policySignals: PolicySignals;
|
||||
limitationReasonCodes: EvidenceLimitationReasonCode[];
|
||||
sourceRefs: string[];
|
||||
}): string[] {
|
||||
const actions: string[] = [];
|
||||
if (input.mode === "focused_grounded") {
|
||||
actions.push("Проверьте 1-2 ключевые записи по source_ref и зафиксируйте итог в рабочем файле проверки.");
|
||||
}
|
||||
if (input.mode === "broad_partial") {
|
||||
actions.push("Сузьте запрос до периода + счета или периода + документа и повторите проверку.");
|
||||
}
|
||||
if (input.mode === "clarification_required") {
|
||||
actions.push("Дайте недостающие якоря (период/счет/объект), иначе сильный factual вывод невозможен.");
|
||||
}
|
||||
if (input.coverageReport.requirements_uncovered.length > 0) {
|
||||
actions.push(`Закройте непокрытые требования: ${input.coverageReport.requirements_uncovered.join(", ")}.`);
|
||||
}
|
||||
if (input.coverageReport.requirements_partially_covered.length > 0) {
|
||||
actions.push(`Доуточните частично покрытые требования: ${input.coverageReport.requirements_partially_covered.join(", ")}.`);
|
||||
}
|
||||
if (input.policySignals.broad_query_detected && input.policySignals.narrowing_strength !== "strong") {
|
||||
actions.push("Добавьте более узкий контекст: тип отклонения, группу документов и бизнес-участок.");
|
||||
}
|
||||
if (input.limitationReasonCodes.includes("snapshot_only")) {
|
||||
actions.push("Сверьте критичные выводы с live source-of-record в 1C.");
|
||||
}
|
||||
if (input.limitationReasonCodes.includes("weak_source_mapping")) {
|
||||
actions.push("Проверьте source mapping для связей document/register по указанным ref.");
|
||||
}
|
||||
if (input.sourceRefs.length > 0) {
|
||||
actions.push(`Начните проверку с source_ref: ${input.sourceRefs.slice(0, 2).join(", ")}.`);
|
||||
}
|
||||
|
||||
return uniqueStrings(actions, 6);
|
||||
}
|
||||
|
||||
function firstMeaningfulFact(results: UnifiedRetrievalResult[]): string | null {
|
||||
const facts = extractTopFacts(results);
|
||||
return facts.length > 0 ? facts[0] : null;
|
||||
}
|
||||
|
||||
function buildPolicyDecision(input: {
|
||||
fallbackType: AssistantFallbackType;
|
||||
coverageReport: RequirementCoverageReport;
|
||||
groundingCheck: AnswerGroundingCheck;
|
||||
okResults: UnifiedRetrievalResult[];
|
||||
partialResults: UnifiedRetrievalResult[];
|
||||
emptyResults: UnifiedRetrievalResult[];
|
||||
errorResults: UnifiedRetrievalResult[];
|
||||
hasSupport: boolean;
|
||||
focusedStrong: boolean;
|
||||
policySignals: PolicySignals;
|
||||
}): PolicyDecision {
|
||||
const hasCoverageGaps =
|
||||
input.coverageReport.requirements_uncovered.length > 0 ||
|
||||
input.coverageReport.requirements_partially_covered.length > 0 ||
|
||||
input.coverageReport.clarification_needed_for.length > 0 ||
|
||||
input.coverageReport.out_of_scope_requirements.length > 0;
|
||||
|
||||
if (input.fallbackType === "out_of_scope" && input.coverageReport.requirements_covered === 0) {
|
||||
return {
|
||||
mode: "out_of_scope",
|
||||
fallback_type: "out_of_scope",
|
||||
reply_type: "out_of_scope"
|
||||
};
|
||||
}
|
||||
|
||||
if (input.groundingCheck.status === "route_mismatch_blocked") {
|
||||
return {
|
||||
mode: "route_mismatch",
|
||||
fallback_type: "partial",
|
||||
reply_type: "route_mismatch_blocked"
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
(input.policySignals.degraded_to === "clarification" && input.policySignals.minimum_evidence_failed) ||
|
||||
(input.fallbackType === "clarification" && !input.hasSupport) ||
|
||||
(input.groundingCheck.status === "no_grounded_answer" && !input.hasSupport)
|
||||
) {
|
||||
return {
|
||||
mode: "clarification_required",
|
||||
fallback_type: "clarification",
|
||||
reply_type: "clarification_required"
|
||||
};
|
||||
}
|
||||
|
||||
if (input.errorResults.length > 0 && input.okResults.length === 0 && input.partialResults.length === 0) {
|
||||
return {
|
||||
mode: "backend_error",
|
||||
fallback_type: input.fallbackType,
|
||||
reply_type: "backend_error"
|
||||
};
|
||||
}
|
||||
|
||||
if (input.okResults.length === 0 && input.partialResults.length === 0 && input.emptyResults.length > 0) {
|
||||
return {
|
||||
mode: "empty",
|
||||
fallback_type: input.fallbackType,
|
||||
reply_type: "empty_but_valid"
|
||||
};
|
||||
}
|
||||
|
||||
if (input.groundingCheck.status === "no_grounded_answer" && input.okResults.length === 0 && input.partialResults.length === 0) {
|
||||
return {
|
||||
mode: "no_grounded",
|
||||
fallback_type: input.fallbackType,
|
||||
reply_type: "no_grounded_answer"
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
input.focusedStrong &&
|
||||
!input.policySignals.broad_query_detected &&
|
||||
!input.policySignals.minimum_evidence_failed &&
|
||||
!hasCoverageGaps
|
||||
) {
|
||||
return {
|
||||
mode: "focused_grounded",
|
||||
fallback_type: "none",
|
||||
reply_type: "factual_with_explanation"
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
input.okResults.length > 0 ||
|
||||
input.partialResults.length > 0 ||
|
||||
hasCoverageGaps ||
|
||||
input.policySignals.minimum_evidence_failed ||
|
||||
input.policySignals.broad_result_flag ||
|
||||
input.groundingCheck.status === "partial"
|
||||
) {
|
||||
return {
|
||||
mode: "broad_partial",
|
||||
fallback_type: "partial",
|
||||
reply_type: "partial_coverage"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "backend_error",
|
||||
fallback_type: "unknown",
|
||||
reply_type: "backend_error"
|
||||
};
|
||||
}
|
||||
|
||||
function buildAnswerSummary(mode: PolicyMode): string {
|
||||
if (mode === "focused_grounded") return "Сформирован прямой ответ на основе подтвержденной опоры.";
|
||||
if (mode === "broad_partial") return "Вывод ограничен: есть частичная опора, но не полный coverage.";
|
||||
if (mode === "clarification_required") return "Нужны уточнения: без сужения strong factual вывод ненадежен.";
|
||||
if (mode === "out_of_scope") return "Запрос вне доступного учетного контура.";
|
||||
if (mode === "route_mismatch") return "Результат маршрута не совпал с предметом вопроса.";
|
||||
if (mode === "empty") return "В текущем срезе данных релевантные записи не обнаружены.";
|
||||
if (mode === "no_grounded") return "Недостаточно опоры для обоснованного ответа.";
|
||||
return "Не удалось собрать обоснованный ответ по текущему запросу.";
|
||||
}
|
||||
|
||||
function buildDirectAnswer(input: {
|
||||
mode: PolicyMode;
|
||||
retrievalResults: UnifiedRetrievalResult[];
|
||||
policySignals: PolicySignals;
|
||||
}): string {
|
||||
const topFact = firstMeaningfulFact(input.retrievalResults);
|
||||
if (input.mode === "focused_grounded") {
|
||||
return topFact ?? "Подтвержденный результат получен; можно продолжать предметную проверку без деградации.";
|
||||
}
|
||||
if (input.mode === "broad_partial") {
|
||||
if (topFact) {
|
||||
return `Доступен ограниченный подтвержденный фрагмент: ${topFact}`;
|
||||
}
|
||||
return "Есть только ограниченная опора; вывод дан в частичном режиме без ложной точности.";
|
||||
}
|
||||
if (input.mode === "clarification_required") {
|
||||
return "Текущий запрос слишком широкий или недоопределен; надежный factual вывод пока невозможен.";
|
||||
}
|
||||
if (input.mode === "out_of_scope") {
|
||||
return "Могу отвечать только в пределах данных доступного учетного контура.";
|
||||
}
|
||||
if (input.mode === "route_mismatch") {
|
||||
return "Предмет результата не совпал с предметом вопроса; требуется уточнение фокуса.";
|
||||
}
|
||||
if (input.mode === "empty") {
|
||||
return "В текущем срезе данных проблемные записи по заданному условию не найдены.";
|
||||
}
|
||||
if (input.mode === "no_grounded") {
|
||||
return "Недостаточно подтвержденной опоры для ответа в требуемой точности.";
|
||||
}
|
||||
if (input.policySignals.minimum_evidence_failed) {
|
||||
return "Маршрут отработал, но минимальная evidence-опора не пройдена.";
|
||||
}
|
||||
return "Не удалось сформировать обоснованный ответ; нужно уточнение запроса.";
|
||||
}
|
||||
|
||||
function renderPolicyReply(structure: AnswerStructureV11): string {
|
||||
const mechanismLines: string[] = [`status=${structure.mechanism_block.status}`];
|
||||
if (structure.mechanism_block.mechanism_notes.length > 0) {
|
||||
mechanismLines.push(...structure.mechanism_block.mechanism_notes.map((item) => `note: ${item}`));
|
||||
}
|
||||
if (structure.mechanism_block.limitation_reason_codes.length > 0) {
|
||||
mechanismLines.push(`limitation_codes: ${structure.mechanism_block.limitation_reason_codes.join(", ")}`);
|
||||
}
|
||||
if (structure.mechanism_block.status === "unresolved" && structure.mechanism_block.mechanism_notes.length === 0) {
|
||||
mechanismLines.push("mechanism_note is intentionally omitted due to weak or missing mechanism evidence");
|
||||
}
|
||||
|
||||
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"}`
|
||||
];
|
||||
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("; ")}`);
|
||||
}
|
||||
|
||||
const uncertaintyLines = [
|
||||
...structure.uncertainty_block.open_uncertainties.map((item) => `open: ${item}`),
|
||||
...structure.uncertainty_block.limitations.map((item) => `limit: ${item}`)
|
||||
];
|
||||
if (uncertaintyLines.length === 0) {
|
||||
uncertaintyLines.push("No material uncertainty detected in current scoped answer.");
|
||||
}
|
||||
|
||||
const nextStepLines = [
|
||||
...structure.next_step_block.recommended_actions.map((item) => `action: ${item}`),
|
||||
...structure.next_step_block.clarification_questions.map((item) => `clarify: ${item}`)
|
||||
];
|
||||
if (nextStepLines.length === 0) {
|
||||
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");
|
||||
}
|
||||
|
||||
function composeAssistantAnswerV11(input: ComposeAnswerInput): ComposeAnswerOutput {
|
||||
const fallbackType = fallbackFromSummary(input.routeSummary);
|
||||
const okResults = input.retrievalResults.filter((item) => item.status === "ok");
|
||||
const partialResults = input.retrievalResults.filter((item) => item.status === "partial");
|
||||
const emptyResults = input.retrievalResults.filter((item) => item.status === "empty");
|
||||
const errorResults = input.retrievalResults.filter((item) => item.status === "error");
|
||||
const evidenceItems = flattenEvidence(input.retrievalResults);
|
||||
const policySignals = aggregatePolicySignals(input.retrievalResults);
|
||||
const limitationReasonCodes = collectLimitationReasonCodes(evidenceItems);
|
||||
const sourceRefs = uniqueStrings(
|
||||
evidenceItems
|
||||
.map((item) => item.source_ref?.canonical_ref)
|
||||
.filter((item): item is string => typeof item === "string" && item.trim().length > 0),
|
||||
8
|
||||
);
|
||||
const mechanismNotes = uniqueStrings(
|
||||
evidenceItems
|
||||
.map((item) => item.mechanism_note)
|
||||
.filter((item): item is string => typeof item === "string" && item.trim().length > 0),
|
||||
6
|
||||
);
|
||||
const claimEvidenceLinks = buildClaimEvidenceLinks(input.retrievalResults);
|
||||
const aggregateEvidenceConfidence = aggregateConfidence(input.retrievalResults, evidenceItems);
|
||||
const hasSupport =
|
||||
okResults.length > 0 ||
|
||||
partialResults.length > 0 ||
|
||||
evidenceItems.length > 0 ||
|
||||
input.retrievalResults.some((item) => item.items.length > 0);
|
||||
const hasCoverageGaps =
|
||||
input.coverageReport.requirements_uncovered.length > 0 ||
|
||||
input.coverageReport.requirements_partially_covered.length > 0 ||
|
||||
input.coverageReport.clarification_needed_for.length > 0 ||
|
||||
input.coverageReport.out_of_scope_requirements.length > 0;
|
||||
const hasCriticalEvidenceLimitation =
|
||||
limitationReasonCodes.includes("weak_source_mapping") ||
|
||||
limitationReasonCodes.includes("insufficient_detail");
|
||||
const hasNonLowRouteConfidence = input.retrievalResults.some(
|
||||
(item) => item.status === "ok" && item.confidence !== "low"
|
||||
);
|
||||
const focusedStrong =
|
||||
okResults.length > 0 &&
|
||||
input.groundingCheck.status === "grounded" &&
|
||||
!hasCoverageGaps &&
|
||||
!policySignals.broad_query_detected &&
|
||||
!policySignals.broad_result_flag &&
|
||||
!policySignals.minimum_evidence_failed &&
|
||||
!hasCriticalEvidenceLimitation &&
|
||||
(aggregateEvidenceConfidence !== "low" || hasNonLowRouteConfidence);
|
||||
|
||||
const decision = buildPolicyDecision({
|
||||
fallbackType,
|
||||
coverageReport: input.coverageReport,
|
||||
groundingCheck: input.groundingCheck,
|
||||
okResults,
|
||||
partialResults,
|
||||
emptyResults,
|
||||
errorResults,
|
||||
hasSupport,
|
||||
focusedStrong,
|
||||
policySignals
|
||||
});
|
||||
|
||||
const missingAnchors = detectMissingAnchors(input.userMessage);
|
||||
const clarificationQuestions = buildClarificationQuestions({
|
||||
mode: decision.mode,
|
||||
missingAnchors,
|
||||
coverageReport: input.coverageReport,
|
||||
policySignals
|
||||
});
|
||||
const recommendedActions = buildRecommendedActions({
|
||||
mode: decision.mode,
|
||||
coverageReport: input.coverageReport,
|
||||
policySignals,
|
||||
limitationReasonCodes,
|
||||
sourceRefs
|
||||
});
|
||||
|
||||
const limitations = uniqueStrings(
|
||||
[
|
||||
...limitationReasonCodes.map((code) => limitationReasonToText(code)),
|
||||
...extractLimitations(input.retrievalResults),
|
||||
...input.groundingCheck.reasons,
|
||||
...(policySignals.minimum_evidence_failed ? ["Minimum evidence gate failed for current scope."] : []),
|
||||
...(policySignals.broad_query_detected && policySignals.narrowing_strength === "weak"
|
||||
? ["Broad query remains weakly narrowed; precision is intentionally limited."]
|
||||
: [])
|
||||
],
|
||||
10
|
||||
);
|
||||
const openUncertainties = uniqueStrings(
|
||||
[
|
||||
...input.groundingCheck.missing_requirements,
|
||||
...(decision.mode === "clarification_required" && missingAnchors.period ? ["missing_anchor:period"] : []),
|
||||
...(decision.mode === "clarification_required" && missingAnchors.account ? ["missing_anchor:account"] : []),
|
||||
...(decision.mode === "clarification_required" && missingAnchors.documentOrObject ? ["missing_anchor:document_or_object"] : []),
|
||||
...(decision.mode === "clarification_required" && missingAnchors.counterparty ? ["missing_anchor:counterparty"] : [])
|
||||
],
|
||||
8
|
||||
);
|
||||
|
||||
const mechanismStatus: AnswerStructureV11["mechanism_block"]["status"] =
|
||||
mechanismNotes.length === 0
|
||||
? "unresolved"
|
||||
: limitationReasonCodes.includes("missing_mechanism") || limitationReasonCodes.includes("heuristic_inference")
|
||||
? "limited"
|
||||
: "grounded";
|
||||
|
||||
const answerStructure: AnswerStructureV11 = {
|
||||
schema_version: "answer_structure_v1_1",
|
||||
answer_summary: buildAnswerSummary(decision.mode),
|
||||
direct_answer: buildDirectAnswer({
|
||||
mode: decision.mode,
|
||||
retrievalResults: input.retrievalResults,
|
||||
policySignals
|
||||
}),
|
||||
mechanism_block: {
|
||||
status: mechanismStatus,
|
||||
mechanism_notes: mechanismNotes,
|
||||
limitation_reason_codes: limitationReasonCodes
|
||||
},
|
||||
evidence_block: {
|
||||
evidence_ids: uniqueStrings(evidenceItems.map((item) => item.evidence_id), 10),
|
||||
source_refs: sourceRefs,
|
||||
mechanism_notes: mechanismNotes,
|
||||
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",
|
||||
...(claimEvidenceLinks.length > 0
|
||||
? {
|
||||
claim_evidence_links: claimEvidenceLinks
|
||||
}
|
||||
: {})
|
||||
},
|
||||
uncertainty_block: {
|
||||
open_uncertainties: openUncertainties,
|
||||
limitations
|
||||
},
|
||||
next_step_block: {
|
||||
recommended_actions: recommendedActions,
|
||||
clarification_questions: clarificationQuestions
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
assistant_reply: renderPolicyReply(answerStructure),
|
||||
fallback_type: decision.fallback_type,
|
||||
reply_type: decision.reply_type,
|
||||
answer_structure_v11: answerStructure
|
||||
};
|
||||
}
|
||||
|
||||
function composeExplainableAnswer(input: ComposeAnswerInput, scopeLabel: "full" | "partial"): string {
|
||||
const facts = extractTopFacts(input.retrievalResults);
|
||||
const whyIncluded = extractWhyIncluded(input.retrievalResults);
|
||||
const selectionReasons = extractSelectionReasons(input.retrievalResults);
|
||||
const riskFactors = extractRiskFactors(input.retrievalResults);
|
||||
const interpretation = extractBusinessInterpretation(input.retrievalResults);
|
||||
const limitations = uniqueStrings([...extractLimitations(input.retrievalResults), ...input.groundingCheck.reasons]);
|
||||
const nextSteps = suggestNextStep(input.requirements, input.coverageReport);
|
||||
|
||||
const lead =
|
||||
scopeLabel === "full"
|
||||
? "Итог: запрос обработан по предмету, найденные объекты подтверждены данными контура."
|
||||
: "Итог: запрос обработан частично, ниже подтвержденная часть и ограничения.";
|
||||
|
||||
return [
|
||||
lead,
|
||||
facts.length > 0 ? "Подтвержденные результаты:\n" + formatList(facts) : "",
|
||||
whyIncluded.length > 0 ? "Почему это попало в ответ:\n" + formatList(whyIncluded) : "",
|
||||
selectionReasons.length > 0 ? "Основание отбора:\n" + formatList(selectionReasons) : "",
|
||||
riskFactors.length > 0 ? "Подтверждающие признаки:\n" + formatList(riskFactors) : "",
|
||||
interpretation.length > 0 ? "Практический смысл:\n" + formatList(interpretation) : "",
|
||||
limitations.length > 0 ? "Ограничения:\n" + formatList(limitations) : "",
|
||||
nextSteps.length > 0 ? "Что проверить дальше:\n" + formatList(nextSteps) : ""
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
export function composeAssistantAnswer(input: ComposeAnswerInput): ComposeAnswerOutput {
|
||||
if (input.enableAnswerPolicyV11) {
|
||||
return composeAssistantAnswerV11(input);
|
||||
}
|
||||
|
||||
const fallbackType = fallbackFromSummary(input.routeSummary);
|
||||
const okResults = input.retrievalResults.filter((item) => item.status === "ok");
|
||||
const partialResults = input.retrievalResults.filter((item) => item.status === "partial");
|
||||
const emptyResults = input.retrievalResults.filter((item) => item.status === "empty");
|
||||
const errorResults = input.retrievalResults.filter((item) => item.status === "error");
|
||||
const hasBroadMinimumEvidenceSignal = input.retrievalResults.some(
|
||||
(item) => summaryBoolean(item, "broad_guard_applied") && summaryBoolean(item, "minimum_evidence_failed")
|
||||
);
|
||||
const hasBroadClarificationSignal = input.retrievalResults.some(
|
||||
(item) =>
|
||||
summaryBoolean(item, "broad_guard_applied") &&
|
||||
summaryBoolean(item, "minimum_evidence_failed") &&
|
||||
summaryString(item, "degraded_to") === "clarification"
|
||||
);
|
||||
|
||||
if (fallbackType === "out_of_scope" && input.coverageReport.requirements_covered === 0) {
|
||||
return {
|
||||
assistant_reply:
|
||||
"Я могу отвечать только по данным вашей учетной базы. Этот запрос выходит за рамки доступного контура.",
|
||||
fallback_type: "out_of_scope",
|
||||
reply_type: "out_of_scope"
|
||||
};
|
||||
}
|
||||
|
||||
if (input.groundingCheck.status === "route_mismatch_blocked") {
|
||||
return {
|
||||
assistant_reply: [
|
||||
"Не отправляю финальный ответ, потому что предмет результата не совпал с предметом вопроса.",
|
||||
"Уточните формулировку (например, нужный счет/участок учета), и я выполню повторный проход."
|
||||
].join("\n\n"),
|
||||
fallback_type: "partial",
|
||||
reply_type: "route_mismatch_blocked"
|
||||
};
|
||||
}
|
||||
|
||||
if (input.groundingCheck.status === "no_grounded_answer" && okResults.length === 0 && !hasBroadMinimumEvidenceSignal) {
|
||||
return {
|
||||
assistant_reply:
|
||||
"Пока не удалось собрать предметно подтвержденный ответ по вашему вопросу. Нужны дополнительные уточнения по периоду или объекту проверки.",
|
||||
fallback_type: fallbackType,
|
||||
reply_type: "no_grounded_answer"
|
||||
};
|
||||
}
|
||||
|
||||
if (hasBroadClarificationSignal && okResults.length === 0 && partialResults.length === 0) {
|
||||
return {
|
||||
assistant_reply:
|
||||
"Запрос слишком широкий для надежного вывода по текущей опоре. Уточните период, участок учета или объект проверки, после чего я дам предметный результат.",
|
||||
fallback_type: "clarification",
|
||||
reply_type: "clarification_required"
|
||||
};
|
||||
}
|
||||
|
||||
if (fallbackType === "clarification" && okResults.length === 0 && partialResults.length === 0) {
|
||||
return {
|
||||
assistant_reply: "Уточните, пожалуйста, период, счет, документ или контрагента, чтобы закрыть все части вопроса корректно.",
|
||||
fallback_type: "clarification",
|
||||
reply_type: "clarification_required"
|
||||
};
|
||||
}
|
||||
|
||||
if (errorResults.length > 0 && okResults.length === 0 && partialResults.length === 0) {
|
||||
return {
|
||||
assistant_reply: "Не удалось получить данные из контура. Попробуйте повторить запрос или уточнить формулировку.",
|
||||
fallback_type: fallbackType,
|
||||
reply_type: "backend_error"
|
||||
};
|
||||
}
|
||||
|
||||
if (partialResults.length > 0 && okResults.length === 0) {
|
||||
return {
|
||||
assistant_reply: composeExplainableAnswer(input, "partial"),
|
||||
fallback_type: "partial",
|
||||
reply_type: "partial_coverage"
|
||||
};
|
||||
}
|
||||
|
||||
if (okResults.length === 0 && partialResults.length === 0 && emptyResults.length > 0) {
|
||||
return {
|
||||
assistant_reply: "По заданному условию в текущем срезе данных явных проблемных записей не найдено.",
|
||||
fallback_type: fallbackType,
|
||||
reply_type: "empty_but_valid"
|
||||
};
|
||||
}
|
||||
|
||||
const hasPartialCoverage =
|
||||
input.coverageReport.requirements_uncovered.length > 0 ||
|
||||
input.coverageReport.requirements_partially_covered.length > 0 ||
|
||||
input.coverageReport.clarification_needed_for.length > 0 ||
|
||||
input.coverageReport.out_of_scope_requirements.length > 0 ||
|
||||
input.groundingCheck.status === "partial" ||
|
||||
errorResults.length > 0;
|
||||
|
||||
if (okResults.length > 0 && hasPartialCoverage) {
|
||||
return {
|
||||
assistant_reply: composeExplainableAnswer(input, "partial"),
|
||||
fallback_type: "partial",
|
||||
reply_type: "partial_coverage"
|
||||
};
|
||||
}
|
||||
|
||||
if (okResults.length > 0) {
|
||||
return {
|
||||
assistant_reply: composeExplainableAnswer(input, "full"),
|
||||
fallback_type: "none",
|
||||
reply_type: "factual_with_explanation"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
assistant_reply: "По текущему запросу не удалось построить обоснованный ответ. Уточните формулировку и попробуйте снова.",
|
||||
fallback_type: "unknown",
|
||||
reply_type: "backend_error"
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,261 @@
|
||||
import path from "path";
|
||||
import { ASSISTANT_SESSIONS_DIR } from "../config";
|
||||
import type { AssistantConversationItem, AssistantReplyType, AssistantSessionState } from "../types/assistant";
|
||||
import { ensureDir, writeJsonFile } from "../utils/files";
|
||||
|
||||
interface AssistantTurnLogRecord {
|
||||
turn_id: string;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
human_block: string;
|
||||
human_readable: {
|
||||
question_raw: string;
|
||||
question_understood: string;
|
||||
decomposition: string[];
|
||||
answer: string;
|
||||
reply_type: AssistantReplyType | null;
|
||||
};
|
||||
technical_json: {
|
||||
trace_id: string | null;
|
||||
user_message: AssistantConversationItem;
|
||||
assistant_message: AssistantConversationItem;
|
||||
debug: AssistantConversationItem["debug"];
|
||||
};
|
||||
}
|
||||
|
||||
interface AssistantSessionLogRecord {
|
||||
schema_version: "assistant_session_log_v1";
|
||||
session_id: string;
|
||||
started_at: string;
|
||||
updated_at: string;
|
||||
counters: {
|
||||
total_messages: number;
|
||||
user_messages: number;
|
||||
assistant_messages: number;
|
||||
};
|
||||
trace_ids: string[];
|
||||
reply_types: AssistantReplyType[];
|
||||
investigation_state: AssistantSessionState["investigation_state"];
|
||||
turns: AssistantTurnLogRecord[];
|
||||
conversation: AssistantConversationItem[];
|
||||
last_assistant: {
|
||||
message_id: string | null;
|
||||
reply_type: AssistantReplyType | null;
|
||||
trace_id: string | null;
|
||||
created_at: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
function unique(values: Array<string | null>): string[] {
|
||||
return Array.from(new Set(values.filter((item): item is string => typeof item === "string" && item.length > 0)));
|
||||
}
|
||||
|
||||
function toObject(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function toStringOrNull(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function extractFragments(assistantItem: AssistantConversationItem): Array<Record<string, unknown>> {
|
||||
if (!assistantItem.debug || !Array.isArray(assistantItem.debug.fragments)) {
|
||||
return [];
|
||||
}
|
||||
return assistantItem.debug.fragments
|
||||
.map((item) => toObject(item))
|
||||
.filter((item): item is Record<string, unknown> => item !== null);
|
||||
}
|
||||
|
||||
function extractNormalizedQuestion(userText: string, assistantItem: AssistantConversationItem): string {
|
||||
const normalized = toObject(assistantItem.debug?.normalized);
|
||||
if (normalized) {
|
||||
const fromUserMessageRaw = toStringOrNull(normalized.user_message_raw);
|
||||
if (fromUserMessageRaw) return fromUserMessageRaw;
|
||||
const fromUserQuestionRaw = toStringOrNull(normalized.user_question_raw);
|
||||
if (fromUserQuestionRaw) return fromUserQuestionRaw;
|
||||
const fromNormalizedQuestion = toStringOrNull(normalized.normalized_question);
|
||||
if (fromNormalizedQuestion) return fromNormalizedQuestion;
|
||||
}
|
||||
|
||||
const fragments = extractFragments(assistantItem);
|
||||
if (fragments.length > 0) {
|
||||
const joined = fragments
|
||||
.map((fragment) => toStringOrNull(fragment.normalized_fragment_text) ?? toStringOrNull(fragment.raw_fragment_text))
|
||||
.filter((item): item is string => Boolean(item))
|
||||
.join(" | ");
|
||||
if (joined) {
|
||||
return joined;
|
||||
}
|
||||
}
|
||||
|
||||
return userText;
|
||||
}
|
||||
|
||||
function buildRouteLookup(assistantItem: AssistantConversationItem): Map<string, Record<string, unknown>> {
|
||||
const output = new Map<string, Record<string, unknown>>();
|
||||
if (!assistantItem.debug || !Array.isArray(assistantItem.debug.routes)) {
|
||||
return output;
|
||||
}
|
||||
for (const route of assistantItem.debug.routes) {
|
||||
const routeObject = toObject(route);
|
||||
if (!routeObject) continue;
|
||||
const fragmentId = toStringOrNull(routeObject.fragment_id);
|
||||
if (!fragmentId) continue;
|
||||
output.set(fragmentId, routeObject);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function buildDecompositionLines(assistantItem: AssistantConversationItem): string[] {
|
||||
const fragments = extractFragments(assistantItem);
|
||||
if (fragments.length === 0) {
|
||||
return ["Фрагменты декомпозиции не выделены."];
|
||||
}
|
||||
|
||||
const routeLookup = buildRouteLookup(assistantItem);
|
||||
|
||||
return fragments.map((fragment, index) => {
|
||||
const fragmentId = toStringOrNull(fragment.fragment_id) ?? `F${index + 1}`;
|
||||
const fragmentText =
|
||||
toStringOrNull(fragment.normalized_fragment_text) ??
|
||||
toStringOrNull(fragment.raw_fragment_text) ??
|
||||
"текст фрагмента отсутствует";
|
||||
const executionReadiness = toStringOrNull(fragment.execution_readiness);
|
||||
const routeStatus = toStringOrNull(fragment.route_status);
|
||||
|
||||
const routeObject = routeLookup.get(fragmentId);
|
||||
const route = toStringOrNull(routeObject?.route);
|
||||
const noRouteReason =
|
||||
toStringOrNull(fragment.no_route_reason) ?? toStringOrNull(routeObject?.no_route_reason);
|
||||
|
||||
const parts = [`${fragmentId}: ${fragmentText}`];
|
||||
if (executionReadiness) parts.push(`execution_readiness=${executionReadiness}`);
|
||||
if (routeStatus) parts.push(`route_status=${routeStatus}`);
|
||||
if (route) parts.push(`route=${route}`);
|
||||
if (noRouteReason) parts.push(`no_route_reason=${noRouteReason}`);
|
||||
|
||||
return parts.join("; ");
|
||||
});
|
||||
}
|
||||
|
||||
function toHumanBlock(input: {
|
||||
questionRaw: string;
|
||||
questionUnderstood: string;
|
||||
decomposition: string[];
|
||||
answer: string;
|
||||
}): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`Вопрос: ${input.questionRaw}`);
|
||||
lines.push(`Понято как: ${input.questionUnderstood}`);
|
||||
lines.push("Декомпозиция:");
|
||||
lines.push(...input.decomposition.map((item) => `- ${item}`));
|
||||
lines.push(`Ответ: ${input.answer}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildTurns(items: AssistantConversationItem[]): AssistantTurnLogRecord[] {
|
||||
const turns: AssistantTurnLogRecord[] = [];
|
||||
const pendingUsers: AssistantConversationItem[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.role === "user") {
|
||||
pendingUsers.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const pairedUser = pendingUsers.shift();
|
||||
if (!pairedUser) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const questionRaw = pairedUser.text;
|
||||
const questionUnderstood = extractNormalizedQuestion(questionRaw, item);
|
||||
const decomposition = buildDecompositionLines(item);
|
||||
const answer = item.text;
|
||||
|
||||
turns.push({
|
||||
turn_id: `turn-${turns.length + 1}`,
|
||||
started_at: pairedUser.created_at ?? null,
|
||||
completed_at: item.created_at ?? null,
|
||||
human_block: toHumanBlock({
|
||||
questionRaw,
|
||||
questionUnderstood,
|
||||
decomposition,
|
||||
answer
|
||||
}),
|
||||
human_readable: {
|
||||
question_raw: questionRaw,
|
||||
question_understood: questionUnderstood,
|
||||
decomposition,
|
||||
answer,
|
||||
reply_type: item.reply_type
|
||||
},
|
||||
technical_json: {
|
||||
trace_id: item.trace_id,
|
||||
user_message: pairedUser,
|
||||
assistant_message: item,
|
||||
debug: item.debug
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return turns;
|
||||
}
|
||||
|
||||
export class AssistantSessionLogger {
|
||||
constructor(private readonly rootDir: string = ASSISTANT_SESSIONS_DIR) {}
|
||||
|
||||
public persistSession(session: AssistantSessionState): void {
|
||||
ensureDir(this.rootDir);
|
||||
const filePath = path.resolve(this.rootDir, `${session.session_id}.json`);
|
||||
|
||||
const startedAt = session.items[0]?.created_at ?? session.updated_at;
|
||||
const userMessages = session.items.filter((item) => item.role === "user").length;
|
||||
const assistantMessages = session.items.filter((item) => item.role === "assistant").length;
|
||||
const assistantItems = session.items.filter((item) => item.role === "assistant");
|
||||
const lastAssistant = assistantItems.length > 0 ? assistantItems[assistantItems.length - 1] : null;
|
||||
|
||||
const traceIds = unique(session.items.map((item) => item.trace_id));
|
||||
const replyTypes = Array.from(
|
||||
new Set(
|
||||
session.items
|
||||
.map((item) => item.reply_type)
|
||||
.filter((item): item is AssistantReplyType => typeof item === "string" && item.length > 0)
|
||||
)
|
||||
);
|
||||
const turns = buildTurns(session.items);
|
||||
|
||||
const record: AssistantSessionLogRecord = {
|
||||
schema_version: "assistant_session_log_v1",
|
||||
session_id: session.session_id,
|
||||
started_at: startedAt,
|
||||
updated_at: session.updated_at,
|
||||
counters: {
|
||||
total_messages: session.items.length,
|
||||
user_messages: userMessages,
|
||||
assistant_messages: assistantMessages
|
||||
},
|
||||
trace_ids: traceIds,
|
||||
reply_types: replyTypes,
|
||||
investigation_state: session.investigation_state,
|
||||
turns,
|
||||
conversation: session.items,
|
||||
last_assistant: {
|
||||
message_id: lastAssistant?.message_id ?? null,
|
||||
reply_type: lastAssistant?.reply_type ?? null,
|
||||
trace_id: lastAssistant?.trace_id ?? null,
|
||||
created_at: lastAssistant?.created_at ?? null
|
||||
}
|
||||
};
|
||||
|
||||
writeJsonFile(filePath, record);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import type { AssistantConversationItem, AssistantSessionState } from "../types/assistant";
|
||||
import type { InvestigationState } from "../types/stage1Contracts";
|
||||
import { FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 } from "../config";
|
||||
import { cloneInvestigationState, createEmptyInvestigationState } from "./investigationState";
|
||||
|
||||
const MAX_ITEMS_PER_SESSION = 200;
|
||||
|
||||
function cloneItem(item: AssistantConversationItem): AssistantConversationItem {
|
||||
return {
|
||||
...item,
|
||||
debug: item.debug ? { ...item.debug } : null
|
||||
};
|
||||
}
|
||||
|
||||
function cloneSession(state: AssistantSessionState): AssistantSessionState {
|
||||
return {
|
||||
session_id: state.session_id,
|
||||
updated_at: state.updated_at,
|
||||
items: state.items.map(cloneItem),
|
||||
investigation_state: cloneInvestigationState(state.investigation_state)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSessionShape(state: AssistantSessionState): AssistantSessionState {
|
||||
const legacy = state as AssistantSessionState & {
|
||||
investigation_state?: InvestigationState | null;
|
||||
items?: AssistantConversationItem[];
|
||||
updated_at?: string;
|
||||
};
|
||||
const normalizedItems = Array.isArray(legacy.items) ? legacy.items : [];
|
||||
const investigationState =
|
||||
FEATURE_ASSISTANT_INVESTIGATION_STATE_V1
|
||||
? legacy.investigation_state ?? createEmptyInvestigationState(state.session_id)
|
||||
: legacy.investigation_state ?? null;
|
||||
|
||||
state.items = normalizedItems;
|
||||
state.updated_at = typeof legacy.updated_at === "string" && legacy.updated_at.trim() ? legacy.updated_at : new Date().toISOString();
|
||||
state.investigation_state = investigationState;
|
||||
return state;
|
||||
}
|
||||
|
||||
export class AssistantSessionStore {
|
||||
private readonly sessions = new Map<string, AssistantSessionState>();
|
||||
|
||||
public ensureSession(sessionId?: string): AssistantSessionState {
|
||||
const resolvedId = (sessionId ?? "").trim() || `asst-${nanoid(10)}`;
|
||||
const existing = this.sessions.get(resolvedId);
|
||||
if (existing) {
|
||||
return cloneSession(normalizeSessionShape(existing));
|
||||
}
|
||||
const created: AssistantSessionState = {
|
||||
session_id: resolvedId,
|
||||
updated_at: new Date().toISOString(),
|
||||
items: [],
|
||||
investigation_state: FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 ? createEmptyInvestigationState(resolvedId) : null
|
||||
};
|
||||
this.sessions.set(resolvedId, created);
|
||||
return cloneSession(created);
|
||||
}
|
||||
|
||||
public appendItem(sessionId: string, item: AssistantConversationItem): AssistantConversationItem {
|
||||
const session = this.ensureMutableSession(sessionId);
|
||||
session.items.push(item);
|
||||
if (session.items.length > MAX_ITEMS_PER_SESSION) {
|
||||
session.items = session.items.slice(session.items.length - MAX_ITEMS_PER_SESSION);
|
||||
}
|
||||
session.updated_at = new Date().toISOString();
|
||||
return cloneItem(item);
|
||||
}
|
||||
|
||||
public getSession(sessionId: string): AssistantSessionState | null {
|
||||
const found = this.sessions.get(sessionId);
|
||||
return found ? cloneSession(normalizeSessionShape(found)) : null;
|
||||
}
|
||||
|
||||
public setInvestigationState(sessionId: string, state: InvestigationState | null): InvestigationState | null {
|
||||
const session = this.ensureMutableSession(sessionId);
|
||||
session.investigation_state = cloneInvestigationState(state);
|
||||
session.updated_at = new Date().toISOString();
|
||||
return cloneInvestigationState(session.investigation_state);
|
||||
}
|
||||
|
||||
private ensureMutableSession(sessionId: string): AssistantSessionState {
|
||||
const existing = this.sessions.get(sessionId);
|
||||
if (existing) {
|
||||
return normalizeSessionShape(existing);
|
||||
}
|
||||
const created: AssistantSessionState = {
|
||||
session_id: sessionId,
|
||||
updated_at: new Date().toISOString(),
|
||||
items: [],
|
||||
investigation_state: FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 ? createEmptyInvestigationState(sessionId) : null
|
||||
};
|
||||
this.sessions.set(sessionId, created);
|
||||
return created;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,199 @@
|
||||
import type {
|
||||
AssistantRequirement,
|
||||
RequirementCoverageReport,
|
||||
UnifiedRetrievalResult
|
||||
} from "../types/assistant";
|
||||
import type { RouteHintSummary } from "../types/normalizer";
|
||||
import type {
|
||||
InvestigationLastAnswerMode,
|
||||
InvestigationNarrowingStatus,
|
||||
InvestigationState
|
||||
} from "../types/stage1Contracts";
|
||||
import {
|
||||
INVESTIGATION_MAX_EVIDENCE_REFS,
|
||||
INVESTIGATION_MAX_PRIMARY_ACCOUNTS,
|
||||
INVESTIGATION_MAX_REQUIREMENT_LINKS,
|
||||
INVESTIGATION_MAX_UNCERTAINTIES,
|
||||
INVESTIGATION_STATE_SCHEMA_VERSION
|
||||
} from "../types/stage1Contracts";
|
||||
|
||||
interface UpdateInvestigationStateInput {
|
||||
previous: InvestigationState;
|
||||
timestamp: string;
|
||||
questionId: string;
|
||||
userMessage: string;
|
||||
routeSummary: RouteHintSummary | null;
|
||||
requirements: AssistantRequirement[];
|
||||
coverageReport: RequirementCoverageReport;
|
||||
retrievalResults: UnifiedRetrievalResult[];
|
||||
replyType: InvestigationLastAnswerMode;
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function capStrings(values: string[], max: number): string[] {
|
||||
return uniqueStrings(values).slice(0, max);
|
||||
}
|
||||
|
||||
function detectAccounts(text: string): string[] {
|
||||
return capStrings(text.match(/\b\d{2}(?:\.\d{2})?\b/g) ?? [], INVESTIGATION_MAX_PRIMARY_ACCOUNTS);
|
||||
}
|
||||
|
||||
function detectPeriod(text: string): string | null {
|
||||
const monthly = text.match(/\b(20\d{2})[-/.](0[1-9]|1[0-2])\b/);
|
||||
if (monthly) return `${monthly[1]}-${monthly[2]}`;
|
||||
const yearly = text.match(/\b(20\d{2})\b/);
|
||||
if (yearly) return yearly[1];
|
||||
return null;
|
||||
}
|
||||
|
||||
function deriveDomain(routeSummary: RouteHintSummary | null): string | null {
|
||||
if (!routeSummary) return null;
|
||||
if (routeSummary.mode === "legacy_v1") {
|
||||
return routeSummary.route_hint;
|
||||
}
|
||||
const routes = routeSummary.decisions.map((item) => item.route).filter((route) => route !== "no_route");
|
||||
const uniqueRoutes = uniqueStrings(routes);
|
||||
if (uniqueRoutes.length === 0) {
|
||||
return "no_route";
|
||||
}
|
||||
return uniqueRoutes.join(",");
|
||||
}
|
||||
|
||||
function deriveNarrowingStatus(
|
||||
routeSummary: RouteHintSummary | null,
|
||||
coverageReport: RequirementCoverageReport
|
||||
): InvestigationNarrowingStatus {
|
||||
if (!routeSummary) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (routeSummary.mode === "legacy_v1") {
|
||||
return "not_needed";
|
||||
}
|
||||
|
||||
if (routeSummary.fallback.type === "clarification" || coverageReport.clarification_needed_for.length > 0) {
|
||||
return "needs_clarification";
|
||||
}
|
||||
|
||||
const hasNoRoute = routeSummary.decisions.some((item) => item.route === "no_route");
|
||||
if (hasNoRoute) {
|
||||
return "broad_guarded";
|
||||
}
|
||||
|
||||
return routeSummary.decisions.length > 1 ? "applied" : "not_needed";
|
||||
}
|
||||
|
||||
function deriveQueryModeHint(routeSummary: RouteHintSummary | null): InvestigationState["query_mode_hint"] {
|
||||
if (!routeSummary) {
|
||||
return "investigation_candidate";
|
||||
}
|
||||
if (routeSummary.mode === "legacy_v1") {
|
||||
return "direct_answer";
|
||||
}
|
||||
return routeSummary.fallback.type === "none" ? "direct_answer" : "investigation_candidate";
|
||||
}
|
||||
|
||||
function collectEvidenceRefs(retrievalResults: UnifiedRetrievalResult[]): string[] {
|
||||
const refs = retrievalResults.flatMap((result) => result.evidence.map((item) => item.evidence_id));
|
||||
return capStrings(refs, INVESTIGATION_MAX_EVIDENCE_REFS);
|
||||
}
|
||||
|
||||
function collectOpenUncertainties(
|
||||
coverageReport: RequirementCoverageReport,
|
||||
retrievalResults: UnifiedRetrievalResult[]
|
||||
): string[] {
|
||||
const requirementNotes = [
|
||||
...coverageReport.requirements_uncovered.map((item) => `uncovered:${item}`),
|
||||
...coverageReport.requirements_partially_covered.map((item) => `partial:${item}`),
|
||||
...coverageReport.clarification_needed_for.map((item) => `clarify:${item}`),
|
||||
...coverageReport.out_of_scope_requirements.map((item) => `out_of_scope:${item}`)
|
||||
];
|
||||
const limitationNotes = retrievalResults.flatMap((result) => result.limitations).slice(0, 6);
|
||||
return capStrings([...requirementNotes, ...limitationNotes], INVESTIGATION_MAX_UNCERTAINTIES);
|
||||
}
|
||||
|
||||
export function cloneInvestigationState(state: InvestigationState | null): InvestigationState | null {
|
||||
if (!state) return null;
|
||||
return {
|
||||
...state,
|
||||
focus: {
|
||||
...state.focus,
|
||||
primary_accounts: [...state.focus.primary_accounts]
|
||||
},
|
||||
evidence_refs: [...state.evidence_refs],
|
||||
open_uncertainties: [...state.open_uncertainties],
|
||||
followup_context: state.followup_context
|
||||
? {
|
||||
...state.followup_context,
|
||||
referenced_requirement_ids: [...state.followup_context.referenced_requirement_ids]
|
||||
}
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyInvestigationState(sessionId: string, timestamp = new Date().toISOString()): InvestigationState {
|
||||
return {
|
||||
schema_version: INVESTIGATION_STATE_SCHEMA_VERSION,
|
||||
session_id: sessionId,
|
||||
status: "idle",
|
||||
turn_index: 0,
|
||||
updated_at: timestamp,
|
||||
question_id: null,
|
||||
focus: {
|
||||
domain: null,
|
||||
period: null,
|
||||
primary_accounts: [],
|
||||
active_query_subject: null
|
||||
},
|
||||
narrowing_status: "unknown",
|
||||
evidence_refs: [],
|
||||
open_uncertainties: [],
|
||||
last_answer_mode: null,
|
||||
followup_context: null,
|
||||
query_mode_hint: "direct_answer"
|
||||
};
|
||||
}
|
||||
|
||||
export function updateInvestigationState(input: UpdateInvestigationStateInput): InvestigationState {
|
||||
const previous = input.previous;
|
||||
const focusFromMessage = capStrings(detectAccounts(input.userMessage), INVESTIGATION_MAX_PRIMARY_ACCOUNTS);
|
||||
const requirementIds = capStrings(
|
||||
input.requirements.map((item) => item.requirement_id),
|
||||
INVESTIGATION_MAX_REQUIREMENT_LINKS
|
||||
);
|
||||
const mainRequirement = input.requirements[0]?.requirement_text ?? input.userMessage;
|
||||
|
||||
return {
|
||||
schema_version: INVESTIGATION_STATE_SCHEMA_VERSION,
|
||||
session_id: previous.session_id,
|
||||
status: "active",
|
||||
turn_index: previous.turn_index + 1,
|
||||
updated_at: input.timestamp,
|
||||
question_id: input.questionId,
|
||||
focus: {
|
||||
domain: deriveDomain(input.routeSummary) ?? previous.focus.domain,
|
||||
period: detectPeriod(input.userMessage) ?? previous.focus.period,
|
||||
primary_accounts: capStrings(
|
||||
[...focusFromMessage, ...previous.focus.primary_accounts],
|
||||
INVESTIGATION_MAX_PRIMARY_ACCOUNTS
|
||||
),
|
||||
active_query_subject: mainRequirement.slice(0, 180)
|
||||
},
|
||||
narrowing_status: deriveNarrowingStatus(input.routeSummary, input.coverageReport),
|
||||
evidence_refs: capStrings(
|
||||
[...collectEvidenceRefs(input.retrievalResults), ...previous.evidence_refs],
|
||||
INVESTIGATION_MAX_EVIDENCE_REFS
|
||||
),
|
||||
open_uncertainties: collectOpenUncertainties(input.coverageReport, input.retrievalResults),
|
||||
last_answer_mode: input.replyType,
|
||||
followup_context: {
|
||||
previous_question_id: previous.question_id,
|
||||
last_user_message: input.userMessage.slice(0, 240),
|
||||
referenced_requirement_ids: requirementIds
|
||||
},
|
||||
query_mode_hint: deriveQueryModeHint(input.routeSummary)
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { DEFAULT_OPENAI_BASE_URL, SCHEMAS_DIR } from "../config";
|
||||
import { ApiError } from "../utils/http";
|
||||
|
||||
export interface OpenAIRequestConfig {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
baseUrl?: string;
|
||||
temperature?: number;
|
||||
maxOutputTokens?: number;
|
||||
}
|
||||
|
||||
export interface OpenAIResponseEnvelope {
|
||||
raw: unknown;
|
||||
outputText: string;
|
||||
usage: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
function extractUsage(raw: Record<string, unknown>): {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
} {
|
||||
const usage = (raw.usage ?? {}) as Record<string, unknown>;
|
||||
const input = Number(usage.input_tokens ?? usage.prompt_tokens ?? 0);
|
||||
const output = Number(usage.output_tokens ?? usage.completion_tokens ?? 0);
|
||||
const total = Number(usage.total_tokens ?? input + output);
|
||||
return {
|
||||
input_tokens: Number.isFinite(input) ? input : 0,
|
||||
output_tokens: Number.isFinite(output) ? output : 0,
|
||||
total_tokens: Number.isFinite(total) ? total : 0
|
||||
};
|
||||
}
|
||||
|
||||
function extractOutputText(raw: Record<string, unknown>): string {
|
||||
if (typeof raw.output_text === "string" && raw.output_text.trim().length > 0) {
|
||||
return raw.output_text;
|
||||
}
|
||||
|
||||
const output = raw.output;
|
||||
if (Array.isArray(output)) {
|
||||
for (const item of output) {
|
||||
if (!item || typeof item !== "object") {
|
||||
continue;
|
||||
}
|
||||
const content = (item as Record<string, unknown>).content;
|
||||
if (!Array.isArray(content)) {
|
||||
continue;
|
||||
}
|
||||
for (const c of content) {
|
||||
if (!c || typeof c !== "object") {
|
||||
continue;
|
||||
}
|
||||
const block = c as Record<string, unknown>;
|
||||
if (typeof block.text === "string" && block.text.trim()) {
|
||||
return block.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = raw.response;
|
||||
if (response && typeof response === "object") {
|
||||
const nested = response as Record<string, unknown>;
|
||||
if (typeof nested.output_text === "string" && nested.output_text.trim().length > 0) {
|
||||
return nested.output_text;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Не удалось извлечь output_text из Responses API ответа.", 502, raw);
|
||||
}
|
||||
|
||||
function loadSchemaForTransport(schemaVersion: "v1" | "v2" | "v2_0_1" | "v2_0_2"): Record<string, unknown> {
|
||||
const schemaFile =
|
||||
schemaVersion === "v1"
|
||||
? "normalized_query_v1.json"
|
||||
: schemaVersion === "v2_0_1"
|
||||
? "normalized_query_v2_0_1.json"
|
||||
: schemaVersion === "v2_0_2"
|
||||
? "normalized_query_v2_0_2.json"
|
||||
: "normalized_query_v2.json";
|
||||
const schemaPath = path.resolve(SCHEMAS_DIR, schemaFile);
|
||||
return JSON.parse(fs.readFileSync(schemaPath, "utf-8")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class OpenAIResponsesClient {
|
||||
public async testConnection(config: OpenAIRequestConfig): Promise<{ ok: boolean; model: string }> {
|
||||
const payload = {
|
||||
model: config.model,
|
||||
input: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "ping" }]
|
||||
}
|
||||
],
|
||||
max_output_tokens: 16
|
||||
};
|
||||
await this.post(config, payload);
|
||||
return { ok: true, model: config.model };
|
||||
}
|
||||
|
||||
public async normalize(
|
||||
config: OpenAIRequestConfig,
|
||||
prompt: {
|
||||
systemPrompt: string;
|
||||
developerPrompt: string;
|
||||
domainPrompt: string;
|
||||
userQuestion: string;
|
||||
schemaVersion: "v1" | "v2" | "v2_0_1" | "v2_0_2";
|
||||
controlledRetryInstruction?: string;
|
||||
}
|
||||
): Promise<OpenAIResponseEnvelope> {
|
||||
const schema = loadSchemaForTransport(prompt.schemaVersion);
|
||||
const schemaName =
|
||||
prompt.schemaVersion === "v1"
|
||||
? "normalized_query_v1"
|
||||
: prompt.schemaVersion === "v2_0_1"
|
||||
? "normalized_query_v2_0_1"
|
||||
: prompt.schemaVersion === "v2_0_2"
|
||||
? "normalized_query_v2_0_2"
|
||||
: "normalized_query_v2";
|
||||
|
||||
const developerPrompt = prompt.controlledRetryInstruction
|
||||
? `${prompt.developerPrompt}\n\n${prompt.controlledRetryInstruction}`
|
||||
: prompt.developerPrompt;
|
||||
|
||||
const payload = {
|
||||
model: config.model,
|
||||
temperature: config.temperature ?? 0,
|
||||
max_output_tokens: config.maxOutputTokens ?? 700,
|
||||
input: [
|
||||
{
|
||||
role: "system",
|
||||
content: [{ type: "input_text", text: prompt.systemPrompt }]
|
||||
},
|
||||
{
|
||||
role: "developer",
|
||||
content: [{ type: "input_text", text: developerPrompt }]
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: `${prompt.domainPrompt}\n\nПользовательский вопрос:\n${prompt.userQuestion}`
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
text: {
|
||||
format: {
|
||||
type: "json_schema",
|
||||
name: schemaName,
|
||||
strict: true,
|
||||
schema
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const raw = await this.post(config, payload);
|
||||
const outputText = extractOutputText(raw);
|
||||
return {
|
||||
raw,
|
||||
outputText,
|
||||
usage: extractUsage(raw)
|
||||
};
|
||||
}
|
||||
|
||||
private async post(config: OpenAIRequestConfig, payload: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
if (!config.apiKey || config.apiKey.trim().length < 10) {
|
||||
throw new ApiError("OPENAI_API_KEY_MISSING", "API ключ OpenAI не задан или слишком короткий.", 400);
|
||||
}
|
||||
|
||||
const url = `${(config.baseUrl ?? DEFAULT_OPENAI_BASE_URL).replace(/\/$/, "")}/responses`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let data: Record<string, unknown>;
|
||||
try {
|
||||
data = JSON.parse(text) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new ApiError("OPENAI_NON_JSON_RESPONSE", "OpenAI вернул не-JSON ответ.", 502, { status: response.status, body: text.slice(0, 500) });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorObj = (data.error ?? {}) as Record<string, unknown>;
|
||||
throw new ApiError(
|
||||
"OPENAI_REQUEST_FAILED",
|
||||
String(errorObj.message ?? `OpenAI request failed with status ${response.status}`),
|
||||
response.status,
|
||||
{
|
||||
status: response.status,
|
||||
type: errorObj.type ?? null,
|
||||
code: errorObj.code ?? null
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { DEFAULT_PROMPT_VERSION, PROMPTS_DIR } from "../config";
|
||||
import type { PromptBundle, PromptPreset, PromptVersion } from "../types/preset";
|
||||
|
||||
function readPromptFile(relativePath: string): string {
|
||||
const filePath = path.resolve(PROMPTS_DIR, relativePath);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`Prompt file not found: ${filePath}`);
|
||||
}
|
||||
return fs.readFileSync(filePath, "utf-8").trim();
|
||||
}
|
||||
|
||||
interface BuiltinPromptPresetDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
promptVersion: PromptVersion;
|
||||
schemaNotes: string;
|
||||
files: {
|
||||
system: string;
|
||||
developer: string;
|
||||
domain: string;
|
||||
fewshot: string;
|
||||
};
|
||||
}
|
||||
|
||||
const BUILTIN_PROMPT_PRESETS: Record<PromptVersion, BuiltinPromptPresetDefinition> = {
|
||||
normalizer_v1: {
|
||||
id: "default-normalizer-v1",
|
||||
name: "Стандартный пресет NDC v1",
|
||||
promptVersion: "normalizer_v1",
|
||||
schemaNotes: "Используется схема normalized_query_v1. Строго соблюдать enum/required поля.",
|
||||
files: {
|
||||
system: path.join("system", "default.txt"),
|
||||
developer: path.join("developer", "default.txt"),
|
||||
domain: path.join("domain", "default.txt"),
|
||||
fewshot: path.join("fewshot", "default.txt")
|
||||
}
|
||||
},
|
||||
normalizer_v1_1: {
|
||||
id: "default-normalizer-v1_1",
|
||||
name: "Стандартный пресет NDC v1.1",
|
||||
promptVersion: "normalizer_v1_1",
|
||||
schemaNotes:
|
||||
"v1.1: усиленная taxonomy intent/route и confidence policy. Используется схема normalized_query_v1 без дополнительных полей.",
|
||||
files: {
|
||||
system: path.join("system", "default.txt"),
|
||||
developer: path.join("developer", "normalizer_v1_1.txt"),
|
||||
domain: path.join("domain", "normalizer_domain_v1_1.txt"),
|
||||
fewshot: path.join("fewshot", "normalizer_fewshot_v1_1.txt")
|
||||
}
|
||||
},
|
||||
normalizer_v1_1_1: {
|
||||
id: "default-normalizer-v1_1_1",
|
||||
name: "Стандартный пресет NDC v1.1.1",
|
||||
promptVersion: "normalizer_v1_1_1",
|
||||
schemaNotes:
|
||||
"v1.1.1: surgical patch для period_close_risk, exact drilldown requires и anomaly route escalation. Схема normalized_query_v1 без изменений.",
|
||||
files: {
|
||||
system: path.join("system", "default.txt"),
|
||||
developer: path.join("developer", "normalizer_v1_1_1.txt"),
|
||||
domain: path.join("domain", "normalizer_domain_v1_1.txt"),
|
||||
fewshot: path.join("fewshot", "normalizer_fewshot_v1_1_1.txt")
|
||||
}
|
||||
},
|
||||
normalizer_v1_1_2: {
|
||||
id: "default-normalizer-v1_1_2",
|
||||
name: "Стандартный пресет NDC v1.1.2",
|
||||
promptVersion: "normalizer_v1_1_2",
|
||||
schemaNotes:
|
||||
"v1.1.2: точечный patch границы heavy_analytical vs period_close_risk + confidence guard на boundary кейсах. Схема normalized_query_v1 без изменений.",
|
||||
files: {
|
||||
system: path.join("system", "default.txt"),
|
||||
developer: path.join("developer", "normalizer_v1_1_2.txt"),
|
||||
domain: path.join("domain", "normalizer_domain_v1_1.txt"),
|
||||
fewshot: path.join("fewshot", "normalizer_fewshot_v1_1_2.txt")
|
||||
}
|
||||
},
|
||||
normalizer_v1_1_2_1: {
|
||||
id: "default-normalizer-v1_1_2_1",
|
||||
name: "Стандартный пресет NDC v1.1.2.1",
|
||||
promptVersion: "normalizer_v1_1_2_1",
|
||||
schemaNotes:
|
||||
"v1.1.2.1: stable prompt baseline v1.1.2 + accounting-review phrasing anchors for 30-case validation pack. Схема normalized_query_v1 без изменений.",
|
||||
files: {
|
||||
system: path.join("system", "default.txt"),
|
||||
developer: path.join("developer", "normalizer_v1_1_2_1.txt"),
|
||||
domain: path.join("domain", "normalizer_domain_v1_1.txt"),
|
||||
fewshot: path.join("fewshot", "normalizer_fewshot_v1_1_2_1.txt")
|
||||
}
|
||||
},
|
||||
normalizer_v2: {
|
||||
id: "default-normalizer-v2",
|
||||
name: "Стандартный пресет NDC v2",
|
||||
promptVersion: "normalizer_v2",
|
||||
schemaNotes:
|
||||
"v2: decomposition-first pre-router. LLM returns fragments + scope + flags; deterministic routing happens in code. Схема normalized_query_v2.",
|
||||
files: {
|
||||
system: path.join("system", "default.txt"),
|
||||
developer: path.join("developer", "normalizer_v2.txt"),
|
||||
domain: path.join("domain", "normalizer_domain_v1_1.txt"),
|
||||
fewshot: path.join("fewshot", "normalizer_v2.txt")
|
||||
}
|
||||
},
|
||||
normalizer_v2_0_1: {
|
||||
id: "default-normalizer-v2_0_1",
|
||||
name: "Стандартный пресет NDC v2.0.1",
|
||||
promptVersion: "normalizer_v2_0_1",
|
||||
schemaNotes:
|
||||
"v2.0.1: clarification-threshold policy. Вопросы в контуре и с понятным route должны исполняться без лишних уточнений. Схема normalized_query_v2_0_1.",
|
||||
files: {
|
||||
system: path.join("system", "default.txt"),
|
||||
developer: path.join("developer", "normalizer_v2_0_1.txt"),
|
||||
domain: path.join("domain", "normalizer_domain_v1_1.txt"),
|
||||
fewshot: path.join("fewshot", "normalizer_v2_0_1.txt")
|
||||
}
|
||||
},
|
||||
normalizer_v2_0_2: {
|
||||
id: "default-normalizer-v2_0_2",
|
||||
name: "Стандартный пресет NDC v2.0.2",
|
||||
promptVersion: "normalizer_v2_0_2",
|
||||
schemaNotes:
|
||||
"v2.0.2: execution-state hardening + explicit route_status/no_route_reason. Схема normalized_query_v2_0_2.",
|
||||
files: {
|
||||
system: path.join("system", "default.txt"),
|
||||
developer: path.join("developer", "normalizer_v2_0_2.txt"),
|
||||
domain: path.join("domain", "normalizer_domain_v1_1.txt"),
|
||||
fewshot: path.join("fewshot", "normalizer_v2_0_2.txt")
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function isPromptVersion(value: string | undefined): value is PromptVersion {
|
||||
return (
|
||||
value === "normalizer_v1" ||
|
||||
value === "normalizer_v1_1" ||
|
||||
value === "normalizer_v1_1_1" ||
|
||||
value === "normalizer_v1_1_2" ||
|
||||
value === "normalizer_v1_1_2_1" ||
|
||||
value === "normalizer_v2" ||
|
||||
value === "normalizer_v2_0_1" ||
|
||||
value === "normalizer_v2_0_2"
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePromptVersion(requested?: string): PromptVersion {
|
||||
if (isPromptVersion(requested)) {
|
||||
return requested;
|
||||
}
|
||||
if (isPromptVersion(DEFAULT_PROMPT_VERSION)) {
|
||||
return DEFAULT_PROMPT_VERSION;
|
||||
}
|
||||
return "normalizer_v2_0_2";
|
||||
}
|
||||
|
||||
function loadBuiltinPreset(promptVersion: PromptVersion): PromptPreset {
|
||||
const now = new Date().toISOString();
|
||||
const definition = BUILTIN_PROMPT_PRESETS[promptVersion];
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
prompt_version: definition.promptVersion,
|
||||
systemPrompt: readPromptFile(definition.files.system),
|
||||
developerPrompt: readPromptFile(definition.files.developer),
|
||||
domainPrompt: readPromptFile(definition.files.domain),
|
||||
schemaNotes: definition.schemaNotes,
|
||||
fewShotExamples: readPromptFile(definition.files.fewshot)
|
||||
};
|
||||
}
|
||||
|
||||
export function listBuiltinPromptPresets(): PromptPreset[] {
|
||||
return (Object.keys(BUILTIN_PROMPT_PRESETS) as PromptVersion[]).map((version) => loadBuiltinPreset(version));
|
||||
}
|
||||
|
||||
export function loadDefaultPrompts(promptVersion?: string): PromptPreset {
|
||||
return loadBuiltinPreset(resolvePromptVersion(promptVersion));
|
||||
}
|
||||
|
||||
export function buildPromptBundle(input: {
|
||||
promptVersion?: string;
|
||||
systemPrompt?: string;
|
||||
developerPrompt?: string;
|
||||
domainPrompt?: string;
|
||||
schemaNotes?: string;
|
||||
fewShotExamples?: string;
|
||||
}): PromptBundle {
|
||||
const selectedPromptVersion = resolvePromptVersion(input.promptVersion);
|
||||
const defaults = loadDefaultPrompts(selectedPromptVersion);
|
||||
const systemPrompt = (input.systemPrompt ?? defaults.systemPrompt).trim();
|
||||
const developerPrompt = (input.developerPrompt ?? defaults.developerPrompt).trim();
|
||||
const domainPrompt = (input.domainPrompt ?? defaults.domainPrompt).trim();
|
||||
const schemaNotes = (input.schemaNotes ?? defaults.schemaNotes ?? "").trim();
|
||||
const fewShotExamples = (input.fewShotExamples ?? defaults.fewShotExamples ?? "").trim();
|
||||
const prompt_version = (input.promptVersion ?? defaults.prompt_version).trim() || selectedPromptVersion;
|
||||
|
||||
const sections = [developerPrompt, `Schema notes:\n${schemaNotes}`];
|
||||
if (fewShotExamples) {
|
||||
sections.push(`Few-shot examples:\n${fewShotExamples}`);
|
||||
}
|
||||
|
||||
return {
|
||||
prompt_version,
|
||||
systemPrompt,
|
||||
developerPrompt,
|
||||
domainPrompt,
|
||||
schemaNotes,
|
||||
fewShotExamples,
|
||||
combinedDeveloperPrompt: sections.join("\n\n")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import type {
|
||||
RetrievalConfidence,
|
||||
RetrievalResultStatus,
|
||||
RetrievalResultType,
|
||||
UnifiedRetrievalResult
|
||||
} from "../types/assistant";
|
||||
import { FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1 } from "../config";
|
||||
import { EVIDENCE_SOURCE_REF_SCHEMA_VERSION } from "../types/stage1Contracts";
|
||||
import type {
|
||||
EvidenceConfidence,
|
||||
EvidenceItem,
|
||||
EvidenceLimitationReasonCode,
|
||||
EvidenceKind,
|
||||
EvidencePointer,
|
||||
EvidenceSourceRef
|
||||
} from "../types/stage1Contracts";
|
||||
|
||||
interface RawRetrievalResult {
|
||||
status?: string;
|
||||
result_type?: string;
|
||||
items?: unknown;
|
||||
summary?: unknown;
|
||||
evidence?: unknown;
|
||||
why_included?: unknown;
|
||||
selection_reason?: unknown;
|
||||
risk_factors?: unknown;
|
||||
business_interpretation?: unknown;
|
||||
confidence?: unknown;
|
||||
limitations?: unknown;
|
||||
errors?: unknown;
|
||||
}
|
||||
|
||||
function toObject(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function toStringOrNull(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function toNumberOrNull(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeStatus(value: string | undefined): RetrievalResultStatus {
|
||||
if (value === "ok" || value === "empty" || value === "partial" || value === "error") {
|
||||
return value;
|
||||
}
|
||||
return "error";
|
||||
}
|
||||
|
||||
function normalizeResultType(value: string | undefined): RetrievalResultType {
|
||||
if (value === "list" || value === "summary" || value === "object" || value === "chain" || value === "ranking") {
|
||||
return value;
|
||||
}
|
||||
return "summary";
|
||||
}
|
||||
|
||||
function normalizeObjectArray(value: unknown): Array<Record<string, unknown>> {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.map((item) => (item && typeof item === "object" ? (item as Record<string, unknown>) : null))
|
||||
.filter((item): item is Record<string, unknown> => item !== null);
|
||||
}
|
||||
|
||||
function normalizeSummary(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeErrors(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.map((item) => String(item));
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.map((item) => String(item));
|
||||
}
|
||||
|
||||
function normalizeConfidence(value: unknown): RetrievalConfidence {
|
||||
if (value === "high" || value === "medium" || value === "low") {
|
||||
return value;
|
||||
}
|
||||
return "medium";
|
||||
}
|
||||
|
||||
function parseEvidenceConfidence(value: unknown): EvidenceConfidence | null {
|
||||
if (value === "high" || value === "medium" || value === "low") {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeEvidenceNamespace(value: unknown): EvidencePointer["source"]["namespace"] {
|
||||
const normalized = toStringOrNull(value)?.toLowerCase();
|
||||
if (!normalized) return "unknown";
|
||||
if (normalized === "snapshot_2020" || normalized === "snapshot") return "snapshot_2020";
|
||||
if (normalized === "assistant_derived" || normalized === "derived") return "assistant_derived";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function inferEvidenceKind(item: Record<string, unknown>): EvidenceKind {
|
||||
if (item.mechanism_of_failure !== undefined || item.failed_expected_edge !== undefined || item.expected_next_step !== undefined) {
|
||||
return "mechanism_link";
|
||||
}
|
||||
if (item.risk_score !== undefined || item.zero_guid_values !== undefined || item.unknown_link_count !== undefined) {
|
||||
return "anomaly_signal";
|
||||
}
|
||||
if (item.records_count !== undefined || item.operations_count !== undefined || item.document_refs_count !== undefined) {
|
||||
return "aggregation";
|
||||
}
|
||||
if (item.limitation !== undefined || item.is_snapshot_limited !== undefined) {
|
||||
return "limitation_note";
|
||||
}
|
||||
return "factual_anchor";
|
||||
}
|
||||
|
||||
function inferMechanismNoteLegacy(kind: EvidenceKind, item: Record<string, unknown>): string {
|
||||
const explicit = toStringOrNull(item.mechanism_note);
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
if (kind === "mechanism_link") {
|
||||
const failure = toStringOrNull(item.mechanism_of_failure);
|
||||
if (failure) return failure;
|
||||
return "Mechanism link inferred from retrieval evidence.";
|
||||
}
|
||||
if (kind === "anomaly_signal") {
|
||||
return "Anomaly signal inferred from risk-oriented fields.";
|
||||
}
|
||||
if (kind === "aggregation") {
|
||||
return "Aggregated evidence item.";
|
||||
}
|
||||
if (kind === "limitation_note") {
|
||||
return "Evidence includes explicit limitation hints.";
|
||||
}
|
||||
return "Factual evidence anchor.";
|
||||
}
|
||||
|
||||
interface MechanismNoteResolution {
|
||||
note: string | null;
|
||||
reliable: boolean;
|
||||
}
|
||||
|
||||
function resolveMechanismNote(kind: EvidenceKind, item: Record<string, unknown>): MechanismNoteResolution {
|
||||
const explicit = toStringOrNull(item.mechanism_note);
|
||||
if (explicit) {
|
||||
return {
|
||||
note: explicit,
|
||||
reliable: true
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === "mechanism_link") {
|
||||
const failure = toStringOrNull(item.mechanism_of_failure);
|
||||
if (failure) {
|
||||
return {
|
||||
note: failure,
|
||||
reliable: true
|
||||
};
|
||||
}
|
||||
const failedEdge = toStringOrNull(item.failed_expected_edge);
|
||||
const expectedNext = toStringOrNull(item.expected_next_step);
|
||||
const composed = [failedEdge ? `failed_edge=${failedEdge}` : null, expectedNext ? `expected_next_step=${expectedNext}` : null]
|
||||
.filter((part): part is string => Boolean(part))
|
||||
.join("; ");
|
||||
if (composed) {
|
||||
return {
|
||||
note: composed,
|
||||
reliable: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1) {
|
||||
return {
|
||||
note: inferMechanismNoteLegacy(kind, item),
|
||||
reliable: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
note: null,
|
||||
reliable: false
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEvidenceSourceType(value: unknown, record: Record<string, unknown>): EvidenceItem["source_type"] {
|
||||
const normalized = toStringOrNull(value);
|
||||
if (normalized === "retrieval_item" || normalized === "retrieval_summary" || normalized === "derived") {
|
||||
return normalized;
|
||||
}
|
||||
if (record.records_count !== undefined || record.operations_count !== undefined || record.document_refs_count !== undefined) {
|
||||
return "retrieval_summary";
|
||||
}
|
||||
return "retrieval_item";
|
||||
}
|
||||
|
||||
function readPointer(record: Record<string, unknown>): Record<string, unknown> {
|
||||
const pointer = toObject(record.pointer);
|
||||
return pointer ?? {};
|
||||
}
|
||||
|
||||
interface NormalizedPointerResult {
|
||||
pointer: EvidencePointer;
|
||||
fallback_source_namespace: boolean;
|
||||
fallback_source_entity: boolean;
|
||||
fallback_source_id: boolean;
|
||||
}
|
||||
|
||||
function normalizeEvidencePointer(
|
||||
fragmentId: string,
|
||||
route: string,
|
||||
record: Record<string, unknown>,
|
||||
index: number
|
||||
): NormalizedPointerResult {
|
||||
const pointer = readPointer(record);
|
||||
const source = toObject(pointer.source);
|
||||
const locator = toObject(pointer.locator);
|
||||
|
||||
const sourceEntityCandidate = toStringOrNull(source?.entity) ?? toStringOrNull(record.source_entity);
|
||||
const sourceEntity = sourceEntityCandidate ?? "unknown_entity";
|
||||
const sourceIdCandidate = toStringOrNull(source?.id) ?? toStringOrNull(record.source_id);
|
||||
const sourceId = sourceIdCandidate ?? `${route}:${fragmentId}:${index + 1}`;
|
||||
const period = toStringOrNull(source?.period) ?? toStringOrNull(record.period);
|
||||
const namespace = normalizeEvidenceNamespace(source?.namespace ?? record.source_namespace);
|
||||
|
||||
return {
|
||||
pointer: {
|
||||
fragment_id: toStringOrNull(pointer.fragment_id) ?? fragmentId,
|
||||
route: toStringOrNull(pointer.route) ?? route,
|
||||
source: {
|
||||
namespace,
|
||||
entity: sourceEntity,
|
||||
id: sourceId,
|
||||
period
|
||||
},
|
||||
locator: {
|
||||
field_path: toStringOrNull(locator?.field_path) ?? toStringOrNull(record.field_path),
|
||||
item_index: toNumberOrNull(locator?.item_index) ?? index
|
||||
}
|
||||
},
|
||||
fallback_source_namespace: namespace === "unknown",
|
||||
fallback_source_entity: sourceEntityCandidate === null,
|
||||
fallback_source_id: sourceIdCandidate === null
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalizeSourceRefPart(value: string | null): string {
|
||||
return encodeURIComponent((value ?? "none").trim().toLowerCase());
|
||||
}
|
||||
|
||||
function buildSourceRef(pointer: EvidencePointer): EvidenceSourceRef {
|
||||
return {
|
||||
schema_version: EVIDENCE_SOURCE_REF_SCHEMA_VERSION,
|
||||
namespace: pointer.source.namespace,
|
||||
entity: pointer.source.entity,
|
||||
id: pointer.source.id,
|
||||
period: pointer.source.period,
|
||||
canonical_ref: [
|
||||
EVIDENCE_SOURCE_REF_SCHEMA_VERSION,
|
||||
canonicalizeSourceRefPart(pointer.source.namespace),
|
||||
canonicalizeSourceRefPart(pointer.source.entity),
|
||||
canonicalizeSourceRefPart(pointer.source.id),
|
||||
canonicalizeSourceRefPart(pointer.source.period)
|
||||
].join("|")
|
||||
};
|
||||
}
|
||||
|
||||
function toBoolean(value: unknown): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return value !== 0;
|
||||
if (typeof value === "string") {
|
||||
const lowered = value.trim().toLowerCase();
|
||||
return lowered === "true" || lowered === "1" || lowered === "yes";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function limitationCodeFromText(text: string): EvidenceLimitationReasonCode {
|
||||
const lower = text.toLowerCase();
|
||||
if (/(snapshot|read-only|read only)/i.test(lower)) {
|
||||
return "snapshot_only";
|
||||
}
|
||||
if (/heuristic/i.test(lower)) {
|
||||
return "heuristic_inference";
|
||||
}
|
||||
if (/mechanism/i.test(lower)) {
|
||||
return "missing_mechanism";
|
||||
}
|
||||
if (/(guid|detail|specific)/i.test(lower)) {
|
||||
return "insufficient_detail";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
interface LimitationResolutionInput {
|
||||
record: Record<string, unknown>;
|
||||
sourceType: EvidenceItem["source_type"];
|
||||
evidenceKind: EvidenceKind;
|
||||
mechanismReliable: boolean;
|
||||
mechanismExpected: boolean;
|
||||
pointerWeak: boolean;
|
||||
}
|
||||
|
||||
function resolveEvidenceLimitation(input: LimitationResolutionInput): EvidenceItem["limitation"] {
|
||||
const explicitLimitation = toStringOrNull(input.record.limitation);
|
||||
if (explicitLimitation) {
|
||||
return {
|
||||
reason_code: limitationCodeFromText(explicitLimitation),
|
||||
note: explicitLimitation
|
||||
};
|
||||
}
|
||||
if (toBoolean(input.record.is_snapshot_limited)) {
|
||||
return {
|
||||
reason_code: "snapshot_only",
|
||||
note: null
|
||||
};
|
||||
}
|
||||
if (!FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1) {
|
||||
return null;
|
||||
}
|
||||
if (input.mechanismExpected && !input.mechanismReliable) {
|
||||
return {
|
||||
reason_code: "missing_mechanism",
|
||||
note: null
|
||||
};
|
||||
}
|
||||
if (input.pointerWeak) {
|
||||
return {
|
||||
reason_code: "weak_source_mapping",
|
||||
note: null
|
||||
};
|
||||
}
|
||||
if (input.sourceType === "derived") {
|
||||
return {
|
||||
reason_code: "heuristic_inference",
|
||||
note: null
|
||||
};
|
||||
}
|
||||
if (input.evidenceKind === "limitation_note") {
|
||||
return {
|
||||
reason_code: "unknown",
|
||||
note: null
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function downgradeConfidence(value: EvidenceConfidence): EvidenceConfidence {
|
||||
if (value === "high") return "medium";
|
||||
if (value === "medium") return "low";
|
||||
return "low";
|
||||
}
|
||||
|
||||
interface ConfidenceResolutionInput {
|
||||
explicitConfidence: EvidenceConfidence | null;
|
||||
sourceType: EvidenceItem["source_type"];
|
||||
mechanismReliable: boolean;
|
||||
mechanismExpected: boolean;
|
||||
limitation: EvidenceItem["limitation"];
|
||||
pointerWeak: boolean;
|
||||
}
|
||||
|
||||
function resolveEvidenceConfidence(input: ConfidenceResolutionInput): EvidenceConfidence {
|
||||
if (!FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1) {
|
||||
return input.explicitConfidence ?? "medium";
|
||||
}
|
||||
|
||||
let confidence: EvidenceConfidence = input.explicitConfidence ?? (input.sourceType === "retrieval_item" ? "medium" : "low");
|
||||
|
||||
if (input.limitation?.reason_code === "missing_mechanism" || input.limitation?.reason_code === "weak_source_mapping") {
|
||||
confidence = downgradeConfidence(confidence);
|
||||
}
|
||||
if (input.sourceType === "derived" && !input.explicitConfidence) {
|
||||
confidence = "low";
|
||||
}
|
||||
if (input.mechanismExpected && !input.mechanismReliable) {
|
||||
confidence = "low";
|
||||
}
|
||||
if (input.pointerWeak) {
|
||||
confidence = "low";
|
||||
}
|
||||
|
||||
return confidence;
|
||||
}
|
||||
|
||||
function normalizeEvidenceItems(
|
||||
fragmentId: string,
|
||||
requirementIds: string[],
|
||||
route: string,
|
||||
value: unknown
|
||||
): EvidenceItem[] {
|
||||
const records = normalizeObjectArray(value);
|
||||
return records.map((record, index) => {
|
||||
const evidenceId = toStringOrNull(record.evidence_id) ?? `ev-${fragmentId}-${index + 1}`;
|
||||
const claimRef =
|
||||
toStringOrNull(record.claim_ref) ??
|
||||
(requirementIds[0] ? `requirement:${requirementIds[0]}` : `fragment:${fragmentId}`);
|
||||
const evidenceKind = inferEvidenceKind(record);
|
||||
const sourceType = normalizeEvidenceSourceType(record.source_type, record);
|
||||
const pointerResult = normalizeEvidencePointer(fragmentId, route, record, index);
|
||||
const mechanism = resolveMechanismNote(evidenceKind, record);
|
||||
const mechanismExpected = evidenceKind === "mechanism_link" || evidenceKind === "anomaly_signal" || evidenceKind === "aggregation";
|
||||
const pointerWeak =
|
||||
pointerResult.fallback_source_namespace || pointerResult.fallback_source_entity || pointerResult.fallback_source_id;
|
||||
const limitation = resolveEvidenceLimitation({
|
||||
record,
|
||||
sourceType,
|
||||
evidenceKind,
|
||||
mechanismReliable: mechanism.reliable,
|
||||
mechanismExpected,
|
||||
pointerWeak
|
||||
});
|
||||
const confidence = resolveEvidenceConfidence({
|
||||
explicitConfidence: parseEvidenceConfidence(record.confidence),
|
||||
sourceType,
|
||||
mechanismReliable: mechanism.reliable,
|
||||
mechanismExpected,
|
||||
limitation,
|
||||
pointerWeak
|
||||
});
|
||||
|
||||
return {
|
||||
evidence_id: evidenceId,
|
||||
claim_ref: claimRef,
|
||||
source_type: sourceType,
|
||||
source_ref: buildSourceRef(pointerResult.pointer),
|
||||
pointer: pointerResult.pointer,
|
||||
evidence_kind: evidenceKind,
|
||||
mechanism_note: mechanism.note,
|
||||
confidence,
|
||||
limitation,
|
||||
payload: record
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeRetrievalResult(
|
||||
fragmentId: string,
|
||||
requirementIds: string[],
|
||||
route: string,
|
||||
raw: RawRetrievalResult
|
||||
): UnifiedRetrievalResult {
|
||||
return {
|
||||
fragment_id: fragmentId,
|
||||
requirement_ids: requirementIds,
|
||||
route,
|
||||
status: normalizeStatus(raw.status),
|
||||
result_type: normalizeResultType(raw.result_type),
|
||||
items: normalizeObjectArray(raw.items),
|
||||
summary: normalizeSummary(raw.summary),
|
||||
evidence: normalizeEvidenceItems(fragmentId, requirementIds, route, raw.evidence),
|
||||
why_included: normalizeStringArray(raw.why_included),
|
||||
selection_reason: normalizeStringArray(raw.selection_reason),
|
||||
risk_factors: normalizeStringArray(raw.risk_factors),
|
||||
business_interpretation: normalizeStringArray(raw.business_interpretation),
|
||||
confidence: normalizeConfidence(raw.confidence),
|
||||
limitations: normalizeStringArray(raw.limitations),
|
||||
errors: normalizeErrors(raw.errors)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import type {
|
||||
NoRouteReason,
|
||||
NormalizedPayload,
|
||||
NormalizedQueryV1,
|
||||
NormalizedQueryV2,
|
||||
NormalizedQueryV2_0_1,
|
||||
NormalizedQueryV2_0_2,
|
||||
RouteDecisionV2,
|
||||
RouteHintSummary,
|
||||
RouteHintSummaryV1,
|
||||
RouteHintSummaryV2,
|
||||
RouteStatus,
|
||||
SoftAssumption
|
||||
} from "../types/normalizer";
|
||||
|
||||
function toRouteHintSummaryV1(normalized: NormalizedQueryV1): RouteHintSummaryV1 {
|
||||
return {
|
||||
mode: "legacy_v1",
|
||||
intent_class: normalized.intent_class,
|
||||
route_hint: normalized.route_hint,
|
||||
confidence: normalized.confidence.route_hint,
|
||||
decision_flags: {
|
||||
needs_cross_entity_join: normalized.requires.needs_cross_entity_join,
|
||||
needs_causal_chain: normalized.requires.needs_causal_chain,
|
||||
needs_exact_object_trace: normalized.requires.needs_exact_object_trace,
|
||||
needs_ranking: normalized.requires.needs_ranking,
|
||||
needs_anomaly_summary: normalized.requires.needs_anomaly_summary,
|
||||
needs_runtime_truth: normalized.requires.needs_runtime_truth,
|
||||
needs_period_cut: normalized.requires.needs_period_cut,
|
||||
needs_evidence: normalized.requires.needs_evidence
|
||||
},
|
||||
period_scope: normalized.period_scope,
|
||||
entities: {
|
||||
domain_entities: normalized.domain_entities,
|
||||
accounts_mentioned: normalized.accounts_mentioned,
|
||||
documents_mentioned: normalized.documents_mentioned,
|
||||
registers_mentioned: normalized.registers_mentioned
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
type V2Family = NormalizedQueryV2 | NormalizedQueryV2_0_1 | NormalizedQueryV2_0_2;
|
||||
type V2FamilyFragment = V2Family["fragments"][number];
|
||||
|
||||
function reasonForNoRoute(noRouteReason: NoRouteReason | null | undefined): string {
|
||||
if (noRouteReason === "out_of_scope") {
|
||||
return "Fragment is out-of-scope for company-specific accounting contour.";
|
||||
}
|
||||
if (noRouteReason === "missing_mapping") {
|
||||
return "Fragment is in-scope but route mapping is currently missing.";
|
||||
}
|
||||
if (noRouteReason === "unsupported_fragment_type") {
|
||||
return "Fragment type is not supported by the current deterministic route map.";
|
||||
}
|
||||
return "Fragment requires clarification or is too underspecified for safe routing.";
|
||||
}
|
||||
|
||||
function explicitRouteStatus(fragment: V2FamilyFragment): RouteStatus | null {
|
||||
return "route_status" in fragment ? fragment.route_status : null;
|
||||
}
|
||||
|
||||
function explicitNoRouteReason(fragment: V2FamilyFragment): NoRouteReason | null {
|
||||
return "no_route_reason" in fragment ? fragment.no_route_reason : null;
|
||||
}
|
||||
|
||||
function executionReadiness(fragment: V2FamilyFragment): RouteDecisionV2["execution_readiness"] {
|
||||
return "execution_readiness" in fragment ? fragment.execution_readiness : null;
|
||||
}
|
||||
|
||||
function clarificationReason(fragment: V2FamilyFragment): string | null {
|
||||
return "clarification_reason" in fragment ? fragment.clarification_reason : null;
|
||||
}
|
||||
|
||||
function softAssumptions(fragment: V2FamilyFragment): SoftAssumption[] {
|
||||
return "soft_assumption_used" in fragment ? fragment.soft_assumption_used : [];
|
||||
}
|
||||
|
||||
function buildNoRouteDecision(fragment: V2FamilyFragment, noRouteReason: NoRouteReason | null): RouteDecisionV2 {
|
||||
return {
|
||||
fragment_id: fragment.fragment_id,
|
||||
domain_relevance: fragment.domain_relevance,
|
||||
business_scope: fragment.business_scope,
|
||||
candidate_labels: fragment.candidate_labels,
|
||||
decision_flags: fragment.flags,
|
||||
execution_readiness: executionReadiness(fragment),
|
||||
clarification_reason: clarificationReason(fragment),
|
||||
soft_assumption_used: softAssumptions(fragment),
|
||||
route_status: "no_route",
|
||||
no_route_reason: noRouteReason ?? "insufficient_specificity",
|
||||
route: "no_route",
|
||||
reason: reasonForNoRoute(noRouteReason)
|
||||
};
|
||||
}
|
||||
|
||||
function decideRouteForFragment(fragment: V2FamilyFragment): RouteDecisionV2 {
|
||||
const status = explicitRouteStatus(fragment);
|
||||
const noRouteReason = explicitNoRouteReason(fragment);
|
||||
const readiness = executionReadiness(fragment);
|
||||
const clarification = clarificationReason(fragment);
|
||||
const soft = softAssumptions(fragment);
|
||||
|
||||
if (status === "no_route") {
|
||||
return buildNoRouteDecision(fragment, noRouteReason);
|
||||
}
|
||||
|
||||
if (readiness === "needs_clarification" || readiness === "no_route") {
|
||||
return buildNoRouteDecision(fragment, noRouteReason ?? "insufficient_specificity");
|
||||
}
|
||||
|
||||
if (fragment.domain_relevance !== "in_scope") {
|
||||
return buildNoRouteDecision(fragment, "out_of_scope");
|
||||
}
|
||||
|
||||
if (fragment.flags.asks_for_exact_object_trace) {
|
||||
return {
|
||||
fragment_id: fragment.fragment_id,
|
||||
domain_relevance: fragment.domain_relevance,
|
||||
business_scope: fragment.business_scope,
|
||||
candidate_labels: fragment.candidate_labels,
|
||||
decision_flags: fragment.flags,
|
||||
execution_readiness: readiness,
|
||||
clarification_reason: clarification,
|
||||
soft_assumption_used: soft,
|
||||
route_status: "routed",
|
||||
no_route_reason: null,
|
||||
route: "live_mcp_drilldown",
|
||||
reason: "Exact object trace requested."
|
||||
};
|
||||
}
|
||||
|
||||
if (fragment.flags.asks_for_ranking_or_top || fragment.flags.asks_for_period_summary) {
|
||||
return {
|
||||
fragment_id: fragment.fragment_id,
|
||||
domain_relevance: fragment.domain_relevance,
|
||||
business_scope: fragment.business_scope,
|
||||
candidate_labels: fragment.candidate_labels,
|
||||
decision_flags: fragment.flags,
|
||||
execution_readiness: readiness,
|
||||
clarification_reason: clarification,
|
||||
soft_assumption_used: soft,
|
||||
route_status: "routed",
|
||||
no_route_reason: null,
|
||||
route: "batch_refresh_then_store",
|
||||
reason: "Ranking/summary semantics require batch analytical route."
|
||||
};
|
||||
}
|
||||
|
||||
if (fragment.flags.has_multi_entity_scope && fragment.flags.asks_for_chain_explanation) {
|
||||
return {
|
||||
fragment_id: fragment.fragment_id,
|
||||
domain_relevance: fragment.domain_relevance,
|
||||
business_scope: fragment.business_scope,
|
||||
candidate_labels: fragment.candidate_labels,
|
||||
decision_flags: fragment.flags,
|
||||
execution_readiness: readiness,
|
||||
clarification_reason: clarification,
|
||||
soft_assumption_used: soft,
|
||||
route_status: "routed",
|
||||
no_route_reason: null,
|
||||
route: "hybrid_store_plus_live",
|
||||
reason: "Multi-entity causal chain requested."
|
||||
};
|
||||
}
|
||||
|
||||
if (fragment.flags.asks_for_rule_check && !fragment.flags.asks_for_chain_explanation) {
|
||||
return {
|
||||
fragment_id: fragment.fragment_id,
|
||||
domain_relevance: fragment.domain_relevance,
|
||||
business_scope: fragment.business_scope,
|
||||
candidate_labels: fragment.candidate_labels,
|
||||
decision_flags: fragment.flags,
|
||||
execution_readiness: readiness,
|
||||
clarification_reason: clarification,
|
||||
soft_assumption_used: soft,
|
||||
route_status: "routed",
|
||||
no_route_reason: null,
|
||||
route: "store_feature_risk",
|
||||
reason: "Rule-control check without causal decomposition."
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
fragment.flags.asks_for_anomaly_scan &&
|
||||
!fragment.flags.asks_for_ranking_or_top &&
|
||||
!(fragment.flags.has_multi_entity_scope && fragment.flags.asks_for_chain_explanation)
|
||||
) {
|
||||
return {
|
||||
fragment_id: fragment.fragment_id,
|
||||
domain_relevance: fragment.domain_relevance,
|
||||
business_scope: fragment.business_scope,
|
||||
candidate_labels: fragment.candidate_labels,
|
||||
decision_flags: fragment.flags,
|
||||
execution_readiness: readiness,
|
||||
clarification_reason: clarification,
|
||||
soft_assumption_used: soft,
|
||||
route_status: "routed",
|
||||
no_route_reason: null,
|
||||
route: "store_feature_risk",
|
||||
reason: "Anomaly scan without heavy ranking or causal chain."
|
||||
};
|
||||
}
|
||||
|
||||
if (status === "routed") {
|
||||
return {
|
||||
fragment_id: fragment.fragment_id,
|
||||
domain_relevance: fragment.domain_relevance,
|
||||
business_scope: fragment.business_scope,
|
||||
candidate_labels: fragment.candidate_labels,
|
||||
decision_flags: fragment.flags,
|
||||
execution_readiness: readiness,
|
||||
clarification_reason: clarification,
|
||||
soft_assumption_used: soft,
|
||||
route_status: "routed",
|
||||
no_route_reason: null,
|
||||
route: "store_canonical",
|
||||
reason: "Routed fragment without deep analytical or causal signals."
|
||||
};
|
||||
}
|
||||
|
||||
return buildNoRouteDecision(fragment, "missing_mapping");
|
||||
}
|
||||
|
||||
function fallbackMessageFor(type: RouteHintSummaryV2["fallback"]["type"]): string | null {
|
||||
if (type === "out_of_scope") {
|
||||
return "Я работаю только с данными и бухгалтерским контуром текущей компании. Запрос вне доступной предметной области.";
|
||||
}
|
||||
if (type === "clarification") {
|
||||
return "Могу проверить это в контуре компании, но нужно уточнить период, документ, счет или участок учета.";
|
||||
}
|
||||
if (type === "partial") {
|
||||
return "Обработаю только часть запроса, которая относится к данным компании. Остальное выходит за пределы доступного контура.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function simulateDeterministicRouting(normalized: V2Family): RouteHintSummaryV2 {
|
||||
const decisions = normalized.fragments.map((fragment) => decideRouteForFragment(fragment));
|
||||
const inScopeCount = decisions.filter((item) => item.domain_relevance === "in_scope").length;
|
||||
const outOfScopeCount = decisions.filter((item) => item.domain_relevance === "out_of_scope").length;
|
||||
const routedInScopeCount = decisions.filter((item) => item.domain_relevance === "in_scope" && item.route !== "no_route").length;
|
||||
const clarificationInScopeCount = decisions.filter(
|
||||
(item) => item.domain_relevance === "in_scope" && item.execution_readiness === "needs_clarification"
|
||||
).length;
|
||||
const noRouteInScopeCount = decisions.filter((item) => item.domain_relevance === "in_scope" && item.route === "no_route").length;
|
||||
|
||||
let fallbackType: RouteHintSummaryV2["fallback"]["type"] = "none";
|
||||
if (!normalized.message_in_scope || inScopeCount === 0) {
|
||||
fallbackType = "out_of_scope";
|
||||
} else if (routedInScopeCount === 0 && clarificationInScopeCount > 0) {
|
||||
fallbackType = "clarification";
|
||||
} else if (routedInScopeCount === 0 && noRouteInScopeCount > 0) {
|
||||
fallbackType = "clarification";
|
||||
} else if ((inScopeCount > 0 && outOfScopeCount > 0) || (routedInScopeCount > 0 && noRouteInScopeCount > 0)) {
|
||||
fallbackType = "partial";
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "deterministic_v2",
|
||||
message_in_scope: normalized.message_in_scope,
|
||||
scope_confidence: normalized.scope_confidence,
|
||||
planner: {
|
||||
total_fragments: normalized.fragments.length,
|
||||
in_scope_fragments: inScopeCount,
|
||||
out_of_scope_fragments: outOfScopeCount,
|
||||
discarded_fragments: normalized.discarded_fragments.length,
|
||||
contains_multiple_tasks: normalized.contains_multiple_tasks
|
||||
},
|
||||
decisions,
|
||||
fallback: {
|
||||
type: fallbackType,
|
||||
message: fallbackMessageFor(fallbackType)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function toRouteHintSummary(normalized: NormalizedPayload): RouteHintSummary {
|
||||
if (
|
||||
normalized.schema_version === "normalized_query_v2" ||
|
||||
normalized.schema_version === "normalized_query_v2_0_1" ||
|
||||
normalized.schema_version === "normalized_query_v2_0_2"
|
||||
) {
|
||||
return simulateDeterministicRouting(normalized);
|
||||
}
|
||||
return toRouteHintSummaryV1(normalized);
|
||||
}
|
||||
|
||||
export function toRouterInput(normalized: NormalizedPayload): Record<string, unknown> {
|
||||
if (
|
||||
normalized.schema_version === "normalized_query_v2" ||
|
||||
normalized.schema_version === "normalized_query_v2_0_1" ||
|
||||
normalized.schema_version === "normalized_query_v2_0_2"
|
||||
) {
|
||||
return {
|
||||
mode: "deterministic_v2",
|
||||
message_in_scope: normalized.message_in_scope,
|
||||
scope_confidence: normalized.scope_confidence,
|
||||
contains_multiple_tasks: normalized.contains_multiple_tasks,
|
||||
fragments: normalized.fragments.map((fragment) => ({
|
||||
fragment_id: fragment.fragment_id,
|
||||
domain_relevance: fragment.domain_relevance,
|
||||
business_scope: fragment.business_scope,
|
||||
execution_readiness: "execution_readiness" in fragment ? fragment.execution_readiness : null,
|
||||
clarification_reason: "clarification_reason" in fragment ? fragment.clarification_reason : null,
|
||||
soft_assumption_used: "soft_assumption_used" in fragment ? fragment.soft_assumption_used : [],
|
||||
route_status: "route_status" in fragment ? fragment.route_status : null,
|
||||
no_route_reason: "no_route_reason" in fragment ? fragment.no_route_reason : null,
|
||||
flags: fragment.flags,
|
||||
candidate_labels: fragment.candidate_labels,
|
||||
confidence: fragment.confidence
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "legacy_v1",
|
||||
intent_class: normalized.intent_class,
|
||||
decision_flags: {
|
||||
needs_cross_entity_join: normalized.requires.needs_cross_entity_join,
|
||||
needs_causal_chain: normalized.requires.needs_causal_chain,
|
||||
needs_exact_object_trace: normalized.requires.needs_exact_object_trace,
|
||||
needs_ranking: normalized.requires.needs_ranking,
|
||||
needs_anomaly_summary: normalized.requires.needs_anomaly_summary,
|
||||
needs_runtime_truth: normalized.requires.needs_runtime_truth
|
||||
},
|
||||
route_hint: normalized.route_hint,
|
||||
confidence: normalized.confidence.overall,
|
||||
entities: {
|
||||
domain_entities: normalized.domain_entities,
|
||||
accounts_mentioned: normalized.accounts_mentioned,
|
||||
documents_mentioned: normalized.documents_mentioned,
|
||||
registers_mentioned: normalized.registers_mentioned
|
||||
},
|
||||
period_scope: normalized.period_scope
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import Ajv2020, { type ErrorObject, type ValidateFunction } from "ajv/dist/2020";
|
||||
import { SCHEMAS_DIR } from "../config";
|
||||
import type { NormalizedPayload, ValidationResult } from "../types/normalizer";
|
||||
|
||||
type SchemaVersion = "v1" | "v2" | "v2_0_1" | "v2_0_2";
|
||||
|
||||
const validators = new Map<SchemaVersion, ValidateFunction>();
|
||||
|
||||
function schemaPath(version: SchemaVersion): string {
|
||||
if (version === "v1") {
|
||||
return path.resolve(SCHEMAS_DIR, "normalized_query_v1.json");
|
||||
}
|
||||
if (version === "v2_0_1") {
|
||||
return path.resolve(SCHEMAS_DIR, "normalized_query_v2_0_1.json");
|
||||
}
|
||||
if (version === "v2_0_2") {
|
||||
return path.resolve(SCHEMAS_DIR, "normalized_query_v2_0_2.json");
|
||||
}
|
||||
return path.resolve(SCHEMAS_DIR, "normalized_query_v2.json");
|
||||
}
|
||||
|
||||
function loadValidator(version: SchemaVersion): ValidateFunction {
|
||||
const cached = validators.get(version);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const raw = fs.readFileSync(schemaPath(version), "utf-8");
|
||||
const schema = JSON.parse(raw);
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
||||
const compiled = ajv.compile(schema);
|
||||
validators.set(version, compiled);
|
||||
return compiled;
|
||||
}
|
||||
|
||||
function normalizeAjvErrors(errors: ErrorObject[] | null | undefined): string[] {
|
||||
if (!errors || errors.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return errors.map((item) => `${item.instancePath || "/"} ${item.message ?? "validation error"}`.trim());
|
||||
}
|
||||
|
||||
export function validateNormalized(payload: unknown, schemaVersion: SchemaVersion = "v1"): ValidationResult {
|
||||
const check = loadValidator(schemaVersion);
|
||||
const passed = check(payload);
|
||||
return {
|
||||
passed: Boolean(passed),
|
||||
errors: passed ? [] : normalizeAjvErrors(check.errors)
|
||||
};
|
||||
}
|
||||
|
||||
export function assertNormalized(payload: unknown, schemaVersion: SchemaVersion = "v1"): NormalizedPayload {
|
||||
const validation = validateNormalized(payload, schemaVersion);
|
||||
if (!validation.passed) {
|
||||
throw new Error(`Invalid normalized JSON: ${validation.errors.join("; ")}`);
|
||||
}
|
||||
return payload as NormalizedPayload;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { EVAL_CASES_DIR, PRESETS_DIR, TRACES_DIR } from "../config";
|
||||
import { ensureDir, writeJsonFile } from "../utils/files";
|
||||
import type { PromptPreset } from "../types/preset";
|
||||
|
||||
export interface TraceRecord {
|
||||
trace_id: string;
|
||||
timestamp: string;
|
||||
model: string;
|
||||
prompt_version: string;
|
||||
schema_version: string;
|
||||
case_id?: string;
|
||||
user_question_raw: string;
|
||||
context: Record<string, unknown>;
|
||||
request_payload_redacted: Record<string, unknown>;
|
||||
raw_model_response: unknown;
|
||||
parsed_normalized_json: unknown;
|
||||
validation_result: {
|
||||
passed: boolean;
|
||||
errors: string[];
|
||||
};
|
||||
route_hint_summary?: unknown;
|
||||
route_hint: string | null;
|
||||
confidence: string | null;
|
||||
usage: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
latency_ms: number;
|
||||
expected_route?: string;
|
||||
eval_label?: string;
|
||||
eval_mode?: string;
|
||||
request_count_for_case: number;
|
||||
}
|
||||
|
||||
export interface HistoryListItem {
|
||||
trace_id: string;
|
||||
timestamp: string;
|
||||
model: string;
|
||||
question_short: string;
|
||||
confidence: string | null;
|
||||
validation_passed: boolean;
|
||||
route_hint: string | null;
|
||||
save_status: "saved";
|
||||
}
|
||||
|
||||
function redactSecrets(payload: Record<string, unknown>): Record<string, unknown> {
|
||||
const output = { ...payload };
|
||||
delete output.apiKey;
|
||||
return output;
|
||||
}
|
||||
|
||||
export function saveTrace(record: TraceRecord): void {
|
||||
ensureDir(TRACES_DIR);
|
||||
const target = path.resolve(TRACES_DIR, `${record.trace_id}.json`);
|
||||
writeJsonFile(target, record);
|
||||
}
|
||||
|
||||
export function listTraces(limit = 100): HistoryListItem[] {
|
||||
ensureDir(TRACES_DIR);
|
||||
const files = fs
|
||||
.readdirSync(TRACES_DIR)
|
||||
.filter((item) => item.endsWith(".json"))
|
||||
.sort((a, b) => {
|
||||
const pa = path.resolve(TRACES_DIR, a);
|
||||
const pb = path.resolve(TRACES_DIR, b);
|
||||
return fs.statSync(pb).mtimeMs - fs.statSync(pa).mtimeMs;
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
return files.map((fileName) => {
|
||||
const raw = fs.readFileSync(path.resolve(TRACES_DIR, fileName), "utf-8");
|
||||
const item = JSON.parse(raw) as TraceRecord;
|
||||
return {
|
||||
trace_id: item.trace_id,
|
||||
timestamp: item.timestamp,
|
||||
model: item.model,
|
||||
question_short: item.user_question_raw.slice(0, 110),
|
||||
confidence: item.confidence,
|
||||
validation_passed: item.validation_result.passed,
|
||||
route_hint: item.route_hint,
|
||||
save_status: "saved"
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getTrace(traceId: string): TraceRecord | null {
|
||||
ensureDir(TRACES_DIR);
|
||||
const target = path.resolve(TRACES_DIR, `${traceId}.json`);
|
||||
if (!fs.existsSync(target)) {
|
||||
return null;
|
||||
}
|
||||
const raw = fs.readFileSync(target, "utf-8");
|
||||
return JSON.parse(raw) as TraceRecord;
|
||||
}
|
||||
|
||||
export function savePreset(preset: PromptPreset): void {
|
||||
ensureDir(PRESETS_DIR);
|
||||
writeJsonFile(path.resolve(PRESETS_DIR, `${preset.id}.json`), preset);
|
||||
}
|
||||
|
||||
export function listPresets(): PromptPreset[] {
|
||||
ensureDir(PRESETS_DIR);
|
||||
return fs
|
||||
.readdirSync(PRESETS_DIR)
|
||||
.filter((item) => item.endsWith(".json"))
|
||||
.map((fileName) => {
|
||||
const raw = fs.readFileSync(path.resolve(PRESETS_DIR, fileName), "utf-8");
|
||||
return JSON.parse(raw) as PromptPreset;
|
||||
})
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
export function saveEvalCase(casePayload: Record<string, unknown>): string {
|
||||
ensureDir(EVAL_CASES_DIR);
|
||||
const id = String(casePayload.case_id ?? `NQ-${Date.now()}`);
|
||||
writeJsonFile(path.resolve(EVAL_CASES_DIR, `${id}.json`), casePayload);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function redactRequestPayload(payload: Record<string, unknown>): Record<string, unknown> {
|
||||
return redactSecrets(payload);
|
||||
}
|
||||
Reference in New Issue
Block a user