АДРЕСНЫЙ РЕЖИМ - авторан история - базовая версия
This commit is contained in:
@@ -0,0 +1,821 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { Router } from "express";
|
||||
import { ASSISTANT_SESSIONS_DIR, EVAL_CASES_DIR, REPORTS_DIR } from "../config";
|
||||
import { ApiError, ok } from "../utils/http";
|
||||
|
||||
type AutoRunTarget = "normalizer" | "assistant_stage1" | "assistant_stage2" | "assistant_p0" | "unknown";
|
||||
type AutoRunTrend = "up" | "down" | "flat";
|
||||
|
||||
interface IndexedRun {
|
||||
run_id: string;
|
||||
eval_target: AutoRunTarget;
|
||||
report_path: string;
|
||||
report: Record<string, unknown>;
|
||||
timestamp_iso: string;
|
||||
timestamp_ms: number;
|
||||
}
|
||||
|
||||
interface RunFilters {
|
||||
from_ms: number | null;
|
||||
to_ms: number | null;
|
||||
target: AutoRunTarget | "all";
|
||||
use_mock: boolean | null;
|
||||
prompt_contains: string;
|
||||
mode: string;
|
||||
limit: number;
|
||||
scan_limit: number;
|
||||
}
|
||||
|
||||
interface DomainCoverage {
|
||||
domain: string;
|
||||
total_cases: number;
|
||||
closed_cases: number;
|
||||
}
|
||||
|
||||
interface RunCoverage {
|
||||
closed_cases: number;
|
||||
open_cases: number;
|
||||
domain_coverage: DomainCoverage[];
|
||||
}
|
||||
|
||||
interface RunSummary {
|
||||
run_id: string;
|
||||
eval_target: AutoRunTarget;
|
||||
run_timestamp: string;
|
||||
mode: string | null;
|
||||
llm_provider: string | null;
|
||||
model: string | null;
|
||||
use_mock: boolean | null;
|
||||
prompt_version: string | null;
|
||||
schema_version: string | null;
|
||||
suite_id: string | null;
|
||||
cases_total: number;
|
||||
requests_total: number | null;
|
||||
report_path: string;
|
||||
score_index: number | null;
|
||||
blocking_failures: number;
|
||||
quality_failures: number;
|
||||
closed_cases: number;
|
||||
open_cases: number;
|
||||
domain_coverage: DomainCoverage[];
|
||||
}
|
||||
|
||||
interface CaseSummary {
|
||||
case_id: string;
|
||||
domain: string | null;
|
||||
query_class: string | null;
|
||||
status: "closed" | "open" | "unknown";
|
||||
score_index: number | null;
|
||||
trace_id: string | null;
|
||||
reply_type: string | null;
|
||||
session_id: string;
|
||||
dialog_available: boolean;
|
||||
checks: Record<string, unknown> | null;
|
||||
metric_subscores: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
interface HistoryStats {
|
||||
runs_total: number;
|
||||
by_target: Record<string, number>;
|
||||
blocking_runs: number;
|
||||
quality_gap_runs: number;
|
||||
avg_score_index: number | null;
|
||||
latest_score_index: number | null;
|
||||
previous_score_index: number | null;
|
||||
trend: AutoRunTrend;
|
||||
domain_coverage: DomainCoverage[];
|
||||
}
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function toArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function toStringSafe(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function toNumberSafe(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toBooleanSafe(value: unknown): boolean | null {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const lowered = value.trim().toLowerCase();
|
||||
if (["1", "true", "yes", "on"].includes(lowered)) return true;
|
||||
if (["0", "false", "no", "off"].includes(lowered)) return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseDateMs(value: unknown): number | null {
|
||||
const asString = toStringSafe(value);
|
||||
if (!asString) {
|
||||
return null;
|
||||
}
|
||||
const ms = Date.parse(asString);
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
}
|
||||
|
||||
function clampInt(value: number | null, min: number, max: number, fallback: number): number {
|
||||
if (value === null || !Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
const rounded = Math.trunc(value);
|
||||
if (rounded < min) return min;
|
||||
if (rounded > max) return max;
|
||||
return rounded;
|
||||
}
|
||||
|
||||
function resolveRunTarget(input: { report: Record<string, unknown>; runId: string; reportPath: string }): AutoRunTarget {
|
||||
const explicit = toStringSafe(input.report.eval_target);
|
||||
if (explicit === "assistant_stage1" || explicit === "assistant_stage2" || explicit === "assistant_p0" || explicit === "normalizer") {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
if (input.runId.startsWith("assistant-stage1-")) return "assistant_stage1";
|
||||
if (input.runId.startsWith("assistant-stage2-")) return "assistant_stage2";
|
||||
if (input.runId.startsWith("assistant-p0-")) return "assistant_p0";
|
||||
if (input.runId.startsWith("eval-")) return "normalizer";
|
||||
if (input.reportPath.endsWith(".report.json")) return "normalizer";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function normalizeTimestamp(report: Record<string, unknown>, fileMtimeMs: number): { iso: string; ms: number } {
|
||||
const first = parseDateMs(report.run_timestamp);
|
||||
if (first !== null) {
|
||||
return { iso: new Date(first).toISOString(), ms: first };
|
||||
}
|
||||
const second = parseDateMs(report.timestamp);
|
||||
if (second !== null) {
|
||||
return { iso: new Date(second).toISOString(), ms: second };
|
||||
}
|
||||
return { iso: new Date(fileMtimeMs).toISOString(), ms: fileMtimeMs };
|
||||
}
|
||||
|
||||
function rateToPercent(value: number | null): number | null {
|
||||
if (value === null) return null;
|
||||
if (value <= 1.2) return Math.max(0, Math.min(100, value * 100));
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function scoreToPercent(value: number | null): number | null {
|
||||
if (value === null) return null;
|
||||
if (value <= 5.2) return Math.max(0, Math.min(100, (value / 5) * 100));
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function average(values: Array<number | null>): number | null {
|
||||
const filtered = values.filter((item): item is number => typeof item === "number" && Number.isFinite(item));
|
||||
if (filtered.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const sum = filtered.reduce((acc, item) => acc + item, 0);
|
||||
return Number((sum / filtered.length).toFixed(2));
|
||||
}
|
||||
|
||||
function getMetricRecord(report: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const metrics = toRecord(report.metrics);
|
||||
if (!metrics) return null;
|
||||
const raw = toRecord(metrics.raw);
|
||||
return raw ?? metrics;
|
||||
}
|
||||
|
||||
function computeScoreIndex(report: Record<string, unknown>, target: AutoRunTarget): number | null {
|
||||
const metrics = getMetricRecord(report);
|
||||
if (!metrics) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (target === "assistant_p0") {
|
||||
return average([
|
||||
rateToPercent(toNumberSafe(metrics.problem_first_answer_rate)),
|
||||
scoreToPercent(toNumberSafe(metrics.mechanism_coherence_score)),
|
||||
rateToPercent(1 - (toNumberSafe(metrics.entity_leakage_rate) ?? 1)),
|
||||
scoreToPercent(toNumberSafe(metrics.accountant_actionability_score)),
|
||||
rateToPercent(toNumberSafe(metrics.route_correctness_rate)),
|
||||
rateToPercent(toNumberSafe(metrics.domain_purity_rate)),
|
||||
rateToPercent(toNumberSafe(metrics.limitation_honesty_rate)),
|
||||
rateToPercent(toNumberSafe(metrics.top_problem_unit_match_rate))
|
||||
]);
|
||||
}
|
||||
|
||||
if (target === "assistant_stage1") {
|
||||
return average([
|
||||
rateToPercent(toNumberSafe(metrics.retrieval_differentiation_rate)),
|
||||
rateToPercent(1 - (toNumberSafe(metrics.generic_explanation_rate) ?? 1)),
|
||||
scoreToPercent(toNumberSafe(metrics.accountant_actionability_score)),
|
||||
rateToPercent(1 - (toNumberSafe(metrics.false_confidence_rate) ?? 1)),
|
||||
rateToPercent(1 - (toNumberSafe(metrics.broad_answer_rate) ?? 1)),
|
||||
scoreToPercent(toNumberSafe(metrics.mechanism_specificity_score)),
|
||||
scoreToPercent(toNumberSafe(metrics.followup_context_retention_score))
|
||||
]);
|
||||
}
|
||||
|
||||
if (target === "assistant_stage2") {
|
||||
return average([
|
||||
rateToPercent(toNumberSafe(metrics.problem_unit_precision)),
|
||||
rateToPercent(toNumberSafe(metrics.problem_unit_recall_proxy)),
|
||||
rateToPercent(toNumberSafe(metrics.duplicate_collapse_rate)),
|
||||
scoreToPercent(toNumberSafe(metrics.mechanism_coherence_score)),
|
||||
scoreToPercent(toNumberSafe(metrics.problem_clarity_score)),
|
||||
rateToPercent(toNumberSafe(metrics.problem_first_answer_rate)),
|
||||
rateToPercent(1 - (toNumberSafe(metrics.entity_leakage_rate) ?? 1))
|
||||
]);
|
||||
}
|
||||
|
||||
return average([
|
||||
rateToPercent(toNumberSafe(metrics.schema_validation_pass_rate)),
|
||||
rateToPercent(toNumberSafe(metrics.route_resolution_accuracy) ?? toNumberSafe(metrics.route_hint_accuracy)),
|
||||
rateToPercent(toNumberSafe(metrics.execution_state_consistency_rate) ?? toNumberSafe(metrics.intent_class_accuracy)),
|
||||
rateToPercent(100 - (toNumberSafe(metrics.high_confidence_error_rate) ?? 0))
|
||||
]);
|
||||
}
|
||||
|
||||
function countFailures(report: Record<string, unknown>): { blocking: number; quality: number } {
|
||||
const acceptanceGate = toRecord(report.acceptance_gate);
|
||||
const baselineGate = toRecord(report.baseline_stability_gate);
|
||||
|
||||
const blocking =
|
||||
toArray(acceptanceGate?.blocking_failures).length + toArray(baselineGate?.blocking_regressions).length;
|
||||
|
||||
const quality =
|
||||
toArray(acceptanceGate?.quality_failures).length +
|
||||
toArray(baselineGate?.legacy_quality_failures).length +
|
||||
toArray(baselineGate?.quality_gap_failures).length;
|
||||
|
||||
return { blocking, quality };
|
||||
}
|
||||
|
||||
function caseScoreFromMetricSubscores(metricSubscores: Record<string, unknown> | null): number | null {
|
||||
if (!metricSubscores) return null;
|
||||
const directProduct = scoreToPercent(toNumberSafe(metricSubscores.case_product_score));
|
||||
if (directProduct !== null) {
|
||||
return Number(directProduct.toFixed(2));
|
||||
}
|
||||
|
||||
const candidates: Array<number | null> = [
|
||||
scoreToPercent(toNumberSafe(metricSubscores.problem_clarity_score)),
|
||||
scoreToPercent(toNumberSafe(metricSubscores.mechanism_coherence_score)),
|
||||
rateToPercent(toNumberSafe(metricSubscores.problem_first_answer_rate)),
|
||||
rateToPercent(1 - (toNumberSafe(metricSubscores.entity_leakage_rate) ?? 1)),
|
||||
scoreToPercent(toNumberSafe(metricSubscores.accountant_usefulness_score))
|
||||
];
|
||||
return average(candidates);
|
||||
}
|
||||
|
||||
function isCaseClosed(input: {
|
||||
checks: Record<string, unknown> | null;
|
||||
scoreIndex: number | null;
|
||||
}): boolean | null {
|
||||
const checks = input.checks;
|
||||
if (checks) {
|
||||
const routeCorrect = toBooleanSafe(checks.route_correct);
|
||||
const domainPure = toBooleanSafe(checks.domain_pure);
|
||||
const problemFirst = toBooleanSafe(checks.problem_first_answer);
|
||||
if (routeCorrect !== null || domainPure !== null || problemFirst !== null) {
|
||||
if (routeCorrect === false) return false;
|
||||
if (domainPure === false) return false;
|
||||
if (problemFirst === false) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (typeof input.scoreIndex === "number") {
|
||||
return input.scoreIndex >= 65;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getResultCases(report: Record<string, unknown>): Array<Record<string, unknown>> {
|
||||
return toArray(report.results)
|
||||
.map((item) => toRecord(item))
|
||||
.filter((item): item is Record<string, unknown> => item !== null);
|
||||
}
|
||||
|
||||
function buildCaseSummaries(report: Record<string, unknown>, runId: string, checkDialogAvailability: boolean): CaseSummary[] {
|
||||
const results = getResultCases(report);
|
||||
return results.map((item, index) => {
|
||||
const caseId = toStringSafe(item.case_id) ?? `case-${index + 1}`;
|
||||
const checks = toRecord(item.checks);
|
||||
const metricSubscores = toRecord(item.metric_subscores);
|
||||
const scoreIndex =
|
||||
caseScoreFromMetricSubscores(metricSubscores) ??
|
||||
scoreToPercent(toNumberSafe(item.accountant_usefulness_score)) ??
|
||||
null;
|
||||
const closedState = isCaseClosed({ checks, scoreIndex });
|
||||
const sessionId = `${runId}-${caseId}`;
|
||||
const dialogAvailable = checkDialogAvailability
|
||||
? fs.existsSync(path.resolve(ASSISTANT_SESSIONS_DIR, `${sessionId}.json`))
|
||||
: false;
|
||||
|
||||
return {
|
||||
case_id: caseId,
|
||||
domain: toStringSafe(item.domain),
|
||||
query_class: toStringSafe(item.query_class),
|
||||
status: closedState === null ? "unknown" : closedState ? "closed" : "open",
|
||||
score_index: scoreIndex === null ? null : Number(scoreIndex.toFixed(2)),
|
||||
trace_id: toStringSafe(item.trace_id),
|
||||
reply_type: toStringSafe(item.reply_type),
|
||||
session_id: sessionId,
|
||||
dialog_available: dialogAvailable,
|
||||
checks,
|
||||
metric_subscores: metricSubscores
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildCoverageFromCases(cases: CaseSummary[]): RunCoverage {
|
||||
const coverageByDomain = new Map<string, { total: number; closed: number }>();
|
||||
let closedCases = 0;
|
||||
let openCases = 0;
|
||||
|
||||
for (const item of cases) {
|
||||
if (item.status === "closed") closedCases += 1;
|
||||
if (item.status === "open") openCases += 1;
|
||||
|
||||
const domainKey = item.domain ?? "unknown";
|
||||
const current = coverageByDomain.get(domainKey) ?? { total: 0, closed: 0 };
|
||||
current.total += 1;
|
||||
if (item.status === "closed") current.closed += 1;
|
||||
coverageByDomain.set(domainKey, current);
|
||||
}
|
||||
|
||||
const domainCoverage = Array.from(coverageByDomain.entries())
|
||||
.map(([domain, value]) => ({
|
||||
domain,
|
||||
total_cases: value.total,
|
||||
closed_cases: value.closed
|
||||
}))
|
||||
.sort((a, b) => b.total_cases - a.total_cases);
|
||||
|
||||
return {
|
||||
closed_cases: closedCases,
|
||||
open_cases: openCases,
|
||||
domain_coverage: domainCoverage
|
||||
};
|
||||
}
|
||||
|
||||
function collectJsonCandidates(scanLimit: number): Array<{ path: string; mtimeMs: number }> {
|
||||
const candidates: Array<{ path: string; mtimeMs: number }> = [];
|
||||
const sources: Array<{ dir: string; suffix: string }> = [
|
||||
{ dir: REPORTS_DIR, suffix: ".json" },
|
||||
{ dir: EVAL_CASES_DIR, suffix: ".report.json" }
|
||||
];
|
||||
|
||||
for (const source of sources) {
|
||||
if (!fs.existsSync(source.dir)) continue;
|
||||
const entries = fs.readdirSync(source.dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith(source.suffix)) continue;
|
||||
const fullPath = path.resolve(source.dir, entry.name);
|
||||
try {
|
||||
const stat = fs.statSync(fullPath);
|
||||
candidates.push({ path: fullPath, mtimeMs: stat.mtimeMs });
|
||||
} catch {
|
||||
// skip broken file stat
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidates.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, scanLimit);
|
||||
}
|
||||
|
||||
function indexRuns(scanLimit: number): IndexedRun[] {
|
||||
const files = collectJsonCandidates(scanLimit);
|
||||
const dedup = new Map<string, IndexedRun>();
|
||||
|
||||
for (const item of files) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
const raw = fs.readFileSync(item.path, "utf-8");
|
||||
parsed = JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const report = toRecord(parsed);
|
||||
if (!report) continue;
|
||||
const runId = toStringSafe(report.run_id);
|
||||
if (!runId) continue;
|
||||
const evalTarget = resolveRunTarget({ report, runId, reportPath: item.path });
|
||||
const normalizedTime = normalizeTimestamp(report, item.mtimeMs);
|
||||
const indexed: IndexedRun = {
|
||||
run_id: runId,
|
||||
eval_target: evalTarget,
|
||||
report_path: item.path,
|
||||
report,
|
||||
timestamp_iso: normalizedTime.iso,
|
||||
timestamp_ms: normalizedTime.ms
|
||||
};
|
||||
|
||||
const current = dedup.get(runId);
|
||||
if (!current || indexed.timestamp_ms > current.timestamp_ms) {
|
||||
dedup.set(runId, indexed);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(dedup.values()).sort((a, b) => b.timestamp_ms - a.timestamp_ms);
|
||||
}
|
||||
|
||||
function parseFilters(query: Record<string, unknown>): RunFilters {
|
||||
const fromMs = parseDateMs(query.from);
|
||||
const toMs = parseDateMs(query.to);
|
||||
const targetRaw = toStringSafe(query.target)?.toLowerCase() ?? "all";
|
||||
const target =
|
||||
targetRaw === "normalizer" || targetRaw === "assistant_stage1" || targetRaw === "assistant_stage2" || targetRaw === "assistant_p0"
|
||||
? targetRaw
|
||||
: "all";
|
||||
const useMock = toStringSafe(query.use_mock);
|
||||
const useMockFilter = useMock === null || useMock.toLowerCase() === "any" ? null : toBooleanSafe(useMock);
|
||||
const mode = toStringSafe(query.mode)?.toLowerCase() ?? "all";
|
||||
const promptContains = (toStringSafe(query.prompt_contains) ?? "").toLowerCase();
|
||||
const limit = clampInt(toNumberSafe(query.limit), 1, 500, 120);
|
||||
const scanLimit = clampInt(toNumberSafe(query.scan_limit), 50, 5000, 900);
|
||||
|
||||
return {
|
||||
from_ms: fromMs,
|
||||
to_ms: toMs,
|
||||
target,
|
||||
use_mock: useMockFilter,
|
||||
prompt_contains: promptContains,
|
||||
mode,
|
||||
limit,
|
||||
scan_limit: scanLimit
|
||||
};
|
||||
}
|
||||
|
||||
function matchesFilters(run: IndexedRun, filters: RunFilters): boolean {
|
||||
if (filters.from_ms !== null && run.timestamp_ms < filters.from_ms) return false;
|
||||
if (filters.to_ms !== null && run.timestamp_ms > filters.to_ms) return false;
|
||||
if (filters.target !== "all" && run.eval_target !== filters.target) return false;
|
||||
|
||||
const modeValue = (toStringSafe(run.report.mode) ?? "").toLowerCase();
|
||||
if (filters.mode !== "all" && modeValue !== filters.mode) return false;
|
||||
|
||||
if (filters.use_mock !== null) {
|
||||
const useMockValue = toBooleanSafe(run.report.use_mock);
|
||||
if (useMockValue !== filters.use_mock) return false;
|
||||
}
|
||||
|
||||
if (filters.prompt_contains.length > 0) {
|
||||
const promptVersion = (toStringSafe(run.report.prompt_version) ?? "").toLowerCase();
|
||||
if (!promptVersion.includes(filters.prompt_contains)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildRunSummary(run: IndexedRun): RunSummary {
|
||||
const connection = toRecord(run.report.connection);
|
||||
const normalizeConfig = toRecord(run.report.normalize_config) ?? toRecord(run.report.normalizeConfig);
|
||||
const llmProvider =
|
||||
toStringSafe(run.report.llm_provider) ??
|
||||
toStringSafe(run.report.llmProvider) ??
|
||||
toStringSafe(connection?.llm_provider) ??
|
||||
toStringSafe(connection?.llmProvider) ??
|
||||
toStringSafe(normalizeConfig?.llm_provider) ??
|
||||
toStringSafe(normalizeConfig?.llmProvider);
|
||||
const model =
|
||||
toStringSafe(run.report.model) ??
|
||||
toStringSafe(connection?.model) ??
|
||||
toStringSafe(normalizeConfig?.model);
|
||||
const cases = buildCaseSummaries(run.report, run.run_id, false);
|
||||
const coverage = buildCoverageFromCases(cases);
|
||||
const failures = countFailures(run.report);
|
||||
return {
|
||||
run_id: run.run_id,
|
||||
eval_target: run.eval_target,
|
||||
run_timestamp: run.timestamp_iso,
|
||||
mode: toStringSafe(run.report.mode),
|
||||
llm_provider: llmProvider,
|
||||
model,
|
||||
use_mock: toBooleanSafe(run.report.use_mock),
|
||||
prompt_version: toStringSafe(run.report.prompt_version),
|
||||
schema_version: toStringSafe(run.report.schema_version),
|
||||
suite_id: toStringSafe(run.report.suite_id),
|
||||
cases_total: toNumberSafe(run.report.cases_total) ?? cases.length,
|
||||
requests_total: toNumberSafe(toRecord(run.report.budget)?.requests_total),
|
||||
report_path: run.report_path,
|
||||
score_index: computeScoreIndex(run.report, run.eval_target),
|
||||
blocking_failures: failures.blocking,
|
||||
quality_failures: failures.quality,
|
||||
closed_cases: coverage.closed_cases,
|
||||
open_cases: coverage.open_cases,
|
||||
domain_coverage: coverage.domain_coverage
|
||||
};
|
||||
}
|
||||
|
||||
function mergeDomainCoverage(summaries: RunSummary[]): DomainCoverage[] {
|
||||
const merged = new Map<string, { total: number; closed: number }>();
|
||||
for (const summary of summaries) {
|
||||
for (const item of summary.domain_coverage) {
|
||||
const current = merged.get(item.domain) ?? { total: 0, closed: 0 };
|
||||
current.total += item.total_cases;
|
||||
current.closed += item.closed_cases;
|
||||
merged.set(item.domain, current);
|
||||
}
|
||||
}
|
||||
return Array.from(merged.entries())
|
||||
.map(([domain, value]) => ({
|
||||
domain,
|
||||
total_cases: value.total,
|
||||
closed_cases: value.closed
|
||||
}))
|
||||
.sort((a, b) => b.total_cases - a.total_cases);
|
||||
}
|
||||
|
||||
function buildHistoryStats(summaries: RunSummary[]): HistoryStats {
|
||||
const byTarget: Record<string, number> = {};
|
||||
let blockingRuns = 0;
|
||||
let qualityRuns = 0;
|
||||
const scoreValues: number[] = [];
|
||||
|
||||
for (const item of summaries) {
|
||||
byTarget[item.eval_target] = (byTarget[item.eval_target] ?? 0) + 1;
|
||||
if (item.blocking_failures > 0) blockingRuns += 1;
|
||||
if (item.quality_failures > 0) qualityRuns += 1;
|
||||
if (typeof item.score_index === "number") scoreValues.push(item.score_index);
|
||||
}
|
||||
|
||||
const latestScore = typeof summaries[0]?.score_index === "number" ? (summaries[0].score_index as number) : null;
|
||||
const previousScore = typeof summaries[1]?.score_index === "number" ? (summaries[1].score_index as number) : null;
|
||||
const trend: AutoRunTrend =
|
||||
latestScore === null || previousScore === null
|
||||
? "flat"
|
||||
: latestScore > previousScore + 0.5
|
||||
? "up"
|
||||
: latestScore < previousScore - 0.5
|
||||
? "down"
|
||||
: "flat";
|
||||
|
||||
return {
|
||||
runs_total: summaries.length,
|
||||
by_target: byTarget,
|
||||
blocking_runs: blockingRuns,
|
||||
quality_gap_runs: qualityRuns,
|
||||
avg_score_index: scoreValues.length > 0 ? Number((scoreValues.reduce((a, b) => a + b, 0) / scoreValues.length).toFixed(2)) : null,
|
||||
latest_score_index: latestScore,
|
||||
previous_score_index: previousScore,
|
||||
trend,
|
||||
domain_coverage: mergeDomainCoverage(summaries)
|
||||
};
|
||||
}
|
||||
|
||||
function findRunById(runId: string, scanLimit = 3000): IndexedRun | null {
|
||||
const indexed = indexRuns(scanLimit);
|
||||
return indexed.find((item) => item.run_id === runId) ?? null;
|
||||
}
|
||||
|
||||
function buildAssistantModeSummary(dialogRecord: Record<string, unknown> | null): Record<string, unknown> | null {
|
||||
if (!dialogRecord) return null;
|
||||
const conversation = toArray(dialogRecord.conversation)
|
||||
.map((item) => toRecord(item))
|
||||
.filter((item): item is Record<string, unknown> => item !== null);
|
||||
const lastAssistant = [...conversation]
|
||||
.reverse()
|
||||
.find((item) => toStringSafe(item.role) === "assistant");
|
||||
const debug = toRecord(lastAssistant?.debug);
|
||||
return {
|
||||
reply_type: toStringSafe(lastAssistant?.reply_type),
|
||||
trace_id: toStringSafe(lastAssistant?.trace_id),
|
||||
detected_mode: toStringSafe(debug?.detected_mode),
|
||||
execution_lane: toStringSafe(debug?.execution_lane),
|
||||
tool_gate_decision: toStringSafe(debug?.tool_gate_decision),
|
||||
living_router_mode: toStringSafe(debug?.living_router_mode),
|
||||
fallback_type: toStringSafe(debug?.fallback_type)
|
||||
};
|
||||
}
|
||||
|
||||
function loadSessionDialog(runId: string, caseId: string): {
|
||||
source: "assistant_session";
|
||||
session_id: string;
|
||||
messages: Array<Record<string, unknown>>;
|
||||
decomposition: string[];
|
||||
assistant_mode: Record<string, unknown> | null;
|
||||
} | null {
|
||||
const sessionId = `${runId}-${caseId}`;
|
||||
const filePath = path.resolve(ASSISTANT_SESSIONS_DIR, `${sessionId}.json`);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const record = toRecord(parsed);
|
||||
if (!record) return null;
|
||||
|
||||
const conversation = toArray(record.conversation)
|
||||
.map((item) => toRecord(item))
|
||||
.filter((item): item is Record<string, unknown> => item !== null);
|
||||
const messages = conversation.map((item) => ({
|
||||
role: toStringSafe(item.role) ?? "unknown",
|
||||
text: toStringSafe(item.text) ?? "",
|
||||
created_at: toStringSafe(item.created_at),
|
||||
trace_id: toStringSafe(item.trace_id),
|
||||
reply_type: toStringSafe(item.reply_type)
|
||||
}));
|
||||
|
||||
const turns = toArray(record.turns)
|
||||
.map((item) => toRecord(item))
|
||||
.filter((item): item is Record<string, unknown> => item !== null);
|
||||
const lastTurn = turns.length > 0 ? turns[turns.length - 1] : null;
|
||||
const humanReadable = toRecord(lastTurn?.human_readable);
|
||||
const decomposition = toArray(humanReadable?.decomposition)
|
||||
.map((item) => toStringSafe(item))
|
||||
.filter((item): item is string => item !== null);
|
||||
|
||||
return {
|
||||
source: "assistant_session",
|
||||
session_id: sessionId,
|
||||
messages,
|
||||
decomposition,
|
||||
assistant_mode: buildAssistantModeSummary(record)
|
||||
};
|
||||
}
|
||||
|
||||
function buildFallbackDialog(run: IndexedRun, caseId: string): {
|
||||
source: "report_fallback" | "none";
|
||||
session_id: string;
|
||||
messages: Array<Record<string, unknown>>;
|
||||
decomposition: string[];
|
||||
assistant_mode: Record<string, unknown> | null;
|
||||
} {
|
||||
const sessionId = `${run.run_id}-${caseId}`;
|
||||
const results = getResultCases(run.report);
|
||||
const targetCase = results.find((item) => (toStringSafe(item.case_id) ?? "") === caseId) ?? null;
|
||||
if (!targetCase) {
|
||||
return {
|
||||
source: "none",
|
||||
session_id: sessionId,
|
||||
messages: [],
|
||||
decomposition: [],
|
||||
assistant_mode: null
|
||||
};
|
||||
}
|
||||
|
||||
const userText =
|
||||
toStringSafe(targetCase.raw_question) ??
|
||||
toStringSafe(targetCase.user_query_raw) ??
|
||||
`Case ${caseId}`;
|
||||
|
||||
const assistantSummaryParts: string[] = [];
|
||||
const validationPassed = toBooleanSafe(targetCase.validation_passed);
|
||||
if (validationPassed !== null) assistantSummaryParts.push(`validation_passed=${validationPassed}`);
|
||||
const routeMatch = toBooleanSafe(targetCase.route_match);
|
||||
if (routeMatch !== null) assistantSummaryParts.push(`route_match=${routeMatch}`);
|
||||
const intentMatch = toBooleanSafe(targetCase.intent_match);
|
||||
if (intentMatch !== null) assistantSummaryParts.push(`intent_match=${intentMatch}`);
|
||||
const confidence = toStringSafe(targetCase.confidence_overall);
|
||||
if (confidence) assistantSummaryParts.push(`confidence=${confidence}`);
|
||||
const metricSubscores = toRecord(targetCase.metric_subscores);
|
||||
if (metricSubscores) {
|
||||
for (const [key, value] of Object.entries(metricSubscores)) {
|
||||
if (toNumberSafe(value) !== null) {
|
||||
assistantSummaryParts.push(`${key}=${value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (assistantSummaryParts.length === 0) {
|
||||
assistantSummaryParts.push("No structured assistant dialog is available for this case in report artifacts.");
|
||||
}
|
||||
|
||||
return {
|
||||
source: "report_fallback",
|
||||
session_id: sessionId,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
text: userText,
|
||||
created_at: null,
|
||||
trace_id: null,
|
||||
reply_type: null
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
text: assistantSummaryParts.join("\n"),
|
||||
created_at: null,
|
||||
trace_id: toStringSafe(targetCase.trace_id),
|
||||
reply_type: toStringSafe(targetCase.reply_type)
|
||||
}
|
||||
],
|
||||
decomposition: [],
|
||||
assistant_mode: null
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAutoRunsRouter(): Router {
|
||||
const router = Router();
|
||||
|
||||
router.get("/api/autoruns/history", (req, res) => {
|
||||
const filters = parseFilters(req.query as Record<string, unknown>);
|
||||
const indexed = indexRuns(filters.scan_limit);
|
||||
const filtered = indexed.filter((run) => matchesFilters(run, filters)).slice(0, filters.limit);
|
||||
const summaries = filtered.map((run) => buildRunSummary(run));
|
||||
|
||||
const availableTargets = Array.from(new Set(indexed.map((item) => item.eval_target))).sort();
|
||||
const availableModes = Array.from(
|
||||
new Set(indexed.map((item) => toStringSafe(item.report.mode)).filter((item): item is string => item !== null))
|
||||
).sort();
|
||||
const availablePromptVersions = Array.from(
|
||||
new Set(indexed.map((item) => toStringSafe(item.report.prompt_version)).filter((item): item is string => item !== null))
|
||||
).sort();
|
||||
|
||||
ok(res, {
|
||||
ok: true,
|
||||
generated_at: new Date().toISOString(),
|
||||
filters_applied: {
|
||||
from: filters.from_ms === null ? null : new Date(filters.from_ms).toISOString(),
|
||||
to: filters.to_ms === null ? null : new Date(filters.to_ms).toISOString(),
|
||||
target: filters.target,
|
||||
use_mock: filters.use_mock,
|
||||
prompt_contains: filters.prompt_contains,
|
||||
mode: filters.mode,
|
||||
limit: filters.limit,
|
||||
scan_limit: filters.scan_limit
|
||||
},
|
||||
available: {
|
||||
targets: availableTargets,
|
||||
modes: availableModes,
|
||||
prompt_versions: availablePromptVersions
|
||||
},
|
||||
items: summaries,
|
||||
stats: buildHistoryStats(summaries)
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/api/autoruns/history/:run_id", (req, res, next) => {
|
||||
try {
|
||||
const runId = String(req.params.run_id ?? "").trim();
|
||||
if (!runId) {
|
||||
throw new ApiError("INVALID_RUN_ID", "run_id is required", 400);
|
||||
}
|
||||
const run = findRunById(runId);
|
||||
if (!run) {
|
||||
throw new ApiError("AUTORUN_NOT_FOUND", `Run not found: ${runId}`, 404);
|
||||
}
|
||||
const cases = buildCaseSummaries(run.report, run.run_id, true);
|
||||
const coverage = buildCoverageFromCases(cases);
|
||||
|
||||
ok(res, {
|
||||
ok: true,
|
||||
run: buildRunSummary(run),
|
||||
coverage,
|
||||
cases,
|
||||
report: run.report
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/api/autoruns/history/:run_id/case/:case_id/dialog", (req, res, next) => {
|
||||
try {
|
||||
const runId = String(req.params.run_id ?? "").trim();
|
||||
const caseId = String(req.params.case_id ?? "").trim();
|
||||
if (!runId || !caseId) {
|
||||
throw new ApiError("INVALID_DIALOG_REQUEST", "run_id and case_id are required", 400);
|
||||
}
|
||||
const run = findRunById(runId);
|
||||
if (!run) {
|
||||
throw new ApiError("AUTORUN_NOT_FOUND", `Run not found: ${runId}`, 404);
|
||||
}
|
||||
|
||||
const sessionDialog = loadSessionDialog(runId, caseId);
|
||||
const dialog = sessionDialog ?? buildFallbackDialog(run, caseId);
|
||||
ok(res, {
|
||||
ok: true,
|
||||
run_id: runId,
|
||||
case_id: caseId,
|
||||
...dialog
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import express from "express";
|
||||
import { PORT, PRESETS_DIR, TRACES_DIR, EVAL_CASES_DIR, REPORTS_DIR, TIMEZONE, ASSISTANT_SESSIONS_DIR } from "./config";
|
||||
import { buildAccountingAgentRouter } from "./routes/accountingAgent";
|
||||
import { buildAssistantRouter } from "./routes/assistant";
|
||||
import { buildAutoRunsRouter } from "./routes/autoRuns";
|
||||
import { buildEvalRouter } from "./routes/eval";
|
||||
import { buildHistoryRouter } from "./routes/history";
|
||||
import { buildNormalizeRouter } from "./routes/normalize";
|
||||
@@ -59,6 +60,7 @@ export function createApp(): express.Express {
|
||||
app.use(buildNormalizeRouter(services));
|
||||
app.use(buildEvalRouter(services));
|
||||
app.use(buildAssistantRouter(services));
|
||||
app.use(buildAutoRunsRouter());
|
||||
app.use(buildHistoryRouter());
|
||||
app.use(buildPresetsRouter());
|
||||
app.use(buildAccountingAgentRouter(services));
|
||||
|
||||
@@ -499,7 +499,12 @@ function collectAnalyticsStrings(row: Record<string, unknown>): string[] {
|
||||
"Counterparty",
|
||||
"Контрагент",
|
||||
"Contract",
|
||||
"Договор"
|
||||
"Договор",
|
||||
"Organization",
|
||||
"Организация",
|
||||
"ОрганизацияПредставление",
|
||||
"organization",
|
||||
"organization_name"
|
||||
];
|
||||
|
||||
const collected: string[] = [];
|
||||
@@ -512,7 +517,14 @@ function collectAnalyticsStrings(row: Record<string, unknown>): string[] {
|
||||
|
||||
for (const [key, rawValue] of Object.entries(row)) {
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (lowerKey.includes("subconto") || lowerKey.includes("субконто") || lowerKey.includes("контраг") || lowerKey.includes("договор")) {
|
||||
if (
|
||||
lowerKey.includes("subconto") ||
|
||||
lowerKey.includes("субконто") ||
|
||||
lowerKey.includes("контраг") ||
|
||||
lowerKey.includes("договор") ||
|
||||
lowerKey.includes("organization") ||
|
||||
lowerKey.includes("организац")
|
||||
) {
|
||||
const value = valueAsString(rawValue).trim();
|
||||
if (value) {
|
||||
collected.push(value);
|
||||
@@ -624,6 +636,15 @@ function applyAddressFilters(rows: NormalizedAddressRow[], filters: AddressFilte
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.organization && String(filters.organization).trim()) {
|
||||
const needle = String(filters.organization);
|
||||
const before = filtered.length;
|
||||
filtered = filtered.filter((row) => matchesAnchorText(rowSearchableText(row), needle));
|
||||
if (before > 0 && filtered.length === 0 && mismatchReason === null) {
|
||||
mismatchReason = "organization_anchor_not_matched_in_materialized_rows";
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.document_ref && String(filters.document_ref).trim()) {
|
||||
const needle = String(filters.document_ref);
|
||||
const before = filtered.length;
|
||||
|
||||
@@ -338,11 +338,16 @@ function mergeFollowupFilters(
|
||||
const previousCounterparty = toNonEmptyString(previous.counterparty);
|
||||
const previousContract = toNonEmptyString(previous.contract);
|
||||
const previousAccount = toNonEmptyString(previous.account);
|
||||
const previousOrganization = toNonEmptyString(previous.organization);
|
||||
const previousAsOfDate = toNonEmptyString(previous.as_of_date);
|
||||
const previousPeriodFrom = toNonEmptyString(previous.period_from);
|
||||
const previousPeriodTo = toNonEmptyString(previous.period_to);
|
||||
const allTimeRequested = hasAllTimeHint(userMessage);
|
||||
const sameDateRequested = hasSameDateHint(userMessage);
|
||||
if (!toNonEmptyString(merged.organization) && previousOrganization) {
|
||||
merged.organization = previousOrganization;
|
||||
reasons.push("organization_from_followup_context");
|
||||
}
|
||||
|
||||
if (
|
||||
intent === "list_documents_by_counterparty" ||
|
||||
|
||||
@@ -2681,6 +2681,12 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
||||
previousFilters.counterparty = historicalCounterparty;
|
||||
}
|
||||
}
|
||||
if (!toNonEmptyString(previousFilters.organization)) {
|
||||
const historicalOrganization = findRecentAddressFilterValue(items, "organization");
|
||||
if (historicalOrganization) {
|
||||
previousFilters.organization = historicalOrganization;
|
||||
}
|
||||
}
|
||||
if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -3600,7 +3606,7 @@ export function resolveAssistantOrchestrationDecision(input) {
|
||||
}
|
||||
function hasStrongDataIntentSignal(text) {
|
||||
const lower = String(text ?? "").toLowerCase();
|
||||
return /(база|док|документ|проводк|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|оборот|баланс|период|месяц|год|инн|mcp|bank|counterparty|contract|document|ledger|posting|account)/i.test(lower);
|
||||
return /(база|док|документ|проводк|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|оборот|баланс|период|месяц|год|инн|mcp|bank|counterparty|contract|document|ledger|posting|account|организац|компан|контор|фирм)/i.test(lower);
|
||||
}
|
||||
function hasDataRetrievalRequestSignal(text) {
|
||||
const lower = compactWhitespace(String(text ?? "").toLowerCase());
|
||||
@@ -3612,7 +3618,7 @@ function hasDataRetrievalRequestSignal(text) {
|
||||
if (!hasExplicitRetrievalAction && !hasInterrogativeRetrievalAction) {
|
||||
return false;
|
||||
}
|
||||
const hasRetrievalObject = /(1с|база|док|документ|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|период|месяц|год|инн|bank|counterparty|contract|document|account|balance|ledger|posting)/i.test(lower);
|
||||
const hasRetrievalObject = /(1с|база|док|документ|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|период|месяц|год|инн|bank|counterparty|contract|document|account|balance|ledger|posting|организац|компан|контор|фирм|возраст|дата\s+регистрац|регистрац|основан)/i.test(lower);
|
||||
if (!hasRetrievalObject) {
|
||||
return false;
|
||||
}
|
||||
@@ -3622,6 +3628,77 @@ function hasDataRetrievalRequestSignal(text) {
|
||||
const hasMetaCapabilityShape = /(?:мож(?:ем|ешь|ете|но)|уме(?:ешь|ете)|доступ|подключ|чья|как\s+называ(?:ет|ется)|работ(?:ать|аем|аешь|аете)|в\s+тебе|у\s+тебя)/i.test(lower);
|
||||
return !hasMetaCapabilityShape;
|
||||
}
|
||||
function hasOrganizationFactLookupSignal(text) {
|
||||
const repaired = repairAddressMojibake(String(text ?? ""));
|
||||
const normalized = compactWhitespace(repaired.toLowerCase()).replace(/ё/g, "е");
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const hasFactCue = /(?:возраст|сколько\s+лет|дата\s+регистрац|когда\s+(?:зарегистр|создан|основан)|год\s+регистрац|год\s+основан|с\s+какого\s+года|when\s+was\s+(?:it\s+)?(?:registered|founded|created))/i.test(normalized);
|
||||
if (!hasFactCue) {
|
||||
return false;
|
||||
}
|
||||
return /(?:организац|компан|контор|фирм|ооо|ао|зао|ип|альтернатив|лайсвуд|райм|organization|company)/i.test(normalized);
|
||||
}
|
||||
function findLastAssistantLivingChatDebug(items) {
|
||||
if (!Array.isArray(items)) {
|
||||
return null;
|
||||
}
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (!item || item.role !== "assistant") {
|
||||
continue;
|
||||
}
|
||||
if (item.debug && typeof item.debug === "object") {
|
||||
return item.debug;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function hasOrganizationFactFollowupSignal(userMessage, items) {
|
||||
const repaired = repairAddressMojibake(String(userMessage ?? ""));
|
||||
const normalized = compactWhitespace(repaired.toLowerCase()).replace(/ё/g, "е");
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (hasOrganizationFactLookupSignal(normalized)) {
|
||||
return false;
|
||||
}
|
||||
const hasFollowupCue = /(?:^|\s)(?:давай|го|погнали|ок(?:ей)?|хорошо|принято|подтверждаю|запрашивай|запроси|проверь|продолжай|ну\s+давай|да\s+давай)(?=$|[\s,.!?;:])/iu.test(normalized);
|
||||
if (!hasFollowupCue) {
|
||||
return false;
|
||||
}
|
||||
const lastDebug = findLastAssistantLivingChatDebug(items);
|
||||
const lastSource = toNonEmptyString(lastDebug?.living_chat_response_source);
|
||||
const lastGuardReason = toNonEmptyString(lastDebug?.living_chat_grounding_guard_reason);
|
||||
const inOrganizationFactBoundary = lastSource === "deterministic_organization_fact_boundary" ||
|
||||
lastSource === "deterministic_organization_fact_boundary_followup" ||
|
||||
lastGuardReason === "organization_fact_without_live_source_blocked";
|
||||
return inOrganizationFactBoundary;
|
||||
}
|
||||
function shouldEmitOrganizationSelectionReply(userMessage, selectedOrganization) {
|
||||
const selected = normalizeOrganizationScopeValue(selectedOrganization);
|
||||
if (!selected) {
|
||||
return false;
|
||||
}
|
||||
const repaired = repairAddressMojibake(String(userMessage ?? ""));
|
||||
const normalized = compactWhitespace(repaired.toLowerCase()).replace(/ё/g, "е");
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (hasOrganizationFactLookupSignal(normalized) || hasDataRetrievalRequestSignal(normalized) || hasStrongDataIntentSignal(normalized)) {
|
||||
return false;
|
||||
}
|
||||
const hasAnalyticalCue = /(?:какой|какая|какие|когда|сколько|кто|почему|зачем|возраст|дата|регистрац|ндс|налог|контракт|договор|документ|операц|оборот|сумм|остат|сальдо|founded|registered|created)/i.test(normalized);
|
||||
if (hasAnalyticalCue) {
|
||||
return false;
|
||||
}
|
||||
const hasSelectionCue = /(?:давай|го|погнали|ок(?:ей)?|хорошо|отлично|берем|выберем|выбираем|переключ(?:им|аем|ай)|фиксир|работаем|обсудим|тогда)\b/i.test(normalized);
|
||||
if (hasSelectionCue) {
|
||||
return true;
|
||||
}
|
||||
return normalized.length <= 36 && !/[?]/.test(String(userMessage ?? ""));
|
||||
}
|
||||
function hasOperationalAdminActionRequestSignal(text) {
|
||||
const lower = compactWhitespace(String(text ?? "").toLowerCase()).replace(/ё/g, "е");
|
||||
const normalized = lower.replace(/\b1\s*[cс]\b/giu, "1с");
|
||||
@@ -3819,6 +3896,319 @@ function normalizeScopeLabel(value) {
|
||||
function normalizeScopeKey(value) {
|
||||
return repairAddressMojibake(String(value ?? "")).toLowerCase().replace(/ё/g, "е");
|
||||
}
|
||||
const ORGANIZATION_SCOPE_STOPWORDS = new Set([
|
||||
"ооо",
|
||||
"ao",
|
||||
"ао",
|
||||
"зао",
|
||||
"ип",
|
||||
"llc",
|
||||
"ltd",
|
||||
"company",
|
||||
"компания",
|
||||
"организация",
|
||||
"организации",
|
||||
"контора",
|
||||
"конторы",
|
||||
"фирма",
|
||||
"фирмы",
|
||||
"по",
|
||||
"для",
|
||||
"над",
|
||||
"под",
|
||||
"без",
|
||||
"с",
|
||||
"со",
|
||||
"в",
|
||||
"во",
|
||||
"на",
|
||||
"и",
|
||||
"или",
|
||||
"а",
|
||||
"но",
|
||||
"не",
|
||||
"мы",
|
||||
"нам",
|
||||
"наш",
|
||||
"наша",
|
||||
"наше",
|
||||
"наши",
|
||||
"ты",
|
||||
"тебе",
|
||||
"твой",
|
||||
"сейчас",
|
||||
"щас",
|
||||
"тут",
|
||||
"вот",
|
||||
"давай",
|
||||
"го",
|
||||
"погнали",
|
||||
"тогда",
|
||||
"обсудим",
|
||||
"обсуждать",
|
||||
"работать",
|
||||
"работаем",
|
||||
"работаешь",
|
||||
"работаете",
|
||||
"можем",
|
||||
"можно",
|
||||
"какая",
|
||||
"какой",
|
||||
"какие",
|
||||
"чья",
|
||||
"чье",
|
||||
"чьи"
|
||||
]);
|
||||
function normalizeOrganizationScopeValue(value) {
|
||||
const normalized = normalizeScopeLabel(value);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const unwrapped = normalized
|
||||
.replace(/^\\+|\\+$/g, "")
|
||||
.replace(/^"+|"+$/g, "")
|
||||
.replace(/^'+|'+$/g, "")
|
||||
.trim();
|
||||
return unwrapped ? unwrapped : null;
|
||||
}
|
||||
function normalizeOrganizationScopeSearchText(value) {
|
||||
const source = normalizeScopeKey(value);
|
||||
return source
|
||||
.replace(/[^a-zа-я0-9]+/giu, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
function tokenizeOrganizationScope(value) {
|
||||
const normalized = normalizeOrganizationScopeSearchText(value);
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
return normalized
|
||||
.split(" ")
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length >= 3 && !ORGANIZATION_SCOPE_STOPWORDS.has(token));
|
||||
}
|
||||
function organizationTokenVariants(token) {
|
||||
const source = String(token ?? "").trim().toLowerCase();
|
||||
if (!source) {
|
||||
return [];
|
||||
}
|
||||
const variants = new Set([source]);
|
||||
const withoutLongEnding = source.replace(/(?:ами|ями|ого|ему|ому|ыми|ими|иях|ях|ах|ей|ой|ом|ем|ам|ям|ую|юю|ая|яя|ое|ее|ые|ие|ов|ев|ий|ый|ой)$/iu, "");
|
||||
if (withoutLongEnding.length >= 4) {
|
||||
variants.add(withoutLongEnding);
|
||||
}
|
||||
const withoutShortEnding = source.replace(/[аеёиоуыэюя]$/iu, "");
|
||||
if (withoutShortEnding.length >= 4) {
|
||||
variants.add(withoutShortEnding);
|
||||
}
|
||||
return Array.from(variants);
|
||||
}
|
||||
function scoreOrganizationMentionInMessage(message, organization) {
|
||||
const messageNorm = normalizeOrganizationScopeSearchText(message);
|
||||
const organizationNorm = normalizeOrganizationScopeSearchText(organization);
|
||||
if (!messageNorm || !organizationNorm) {
|
||||
return 0;
|
||||
}
|
||||
if (messageNorm.includes(organizationNorm)) {
|
||||
return 10_000 + organizationNorm.length;
|
||||
}
|
||||
const organizationTokens = tokenizeOrganizationScope(organizationNorm);
|
||||
if (organizationTokens.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const messageTokens = tokenizeOrganizationScope(messageNorm);
|
||||
if (messageTokens.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
let matchedTokens = 0;
|
||||
let score = 0;
|
||||
for (const token of organizationTokens) {
|
||||
const variants = organizationTokenVariants(token);
|
||||
let matched = false;
|
||||
let variantScore = 0;
|
||||
for (const variant of variants) {
|
||||
if (!variant) {
|
||||
continue;
|
||||
}
|
||||
if (messageNorm.includes(variant)) {
|
||||
matched = true;
|
||||
variantScore = Math.max(variantScore, variant.length * 5);
|
||||
continue;
|
||||
}
|
||||
const fuzzyMatched = messageTokens.some((messageToken) => {
|
||||
if (messageToken === variant) {
|
||||
return true;
|
||||
}
|
||||
if (messageToken.length >= 5 && variant.length >= 5) {
|
||||
return messageToken.startsWith(variant) || variant.startsWith(messageToken);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (fuzzyMatched) {
|
||||
matched = true;
|
||||
variantScore = Math.max(variantScore, Math.max(20, variant.length * 3));
|
||||
}
|
||||
}
|
||||
if (matched) {
|
||||
matchedTokens += 1;
|
||||
score += variantScore > 0 ? variantScore : 10;
|
||||
}
|
||||
}
|
||||
if (matchedTokens === 0) {
|
||||
return 0;
|
||||
}
|
||||
if (matchedTokens === organizationTokens.length) {
|
||||
score += 400;
|
||||
} else {
|
||||
score += matchedTokens * 50;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
function parseOrganizationsFromDataScopeAssistantText(text) {
|
||||
const source = repairAddressMojibake(String(text ?? ""));
|
||||
if (!source) {
|
||||
return [];
|
||||
}
|
||||
const extracted = [];
|
||||
const singleMatch = source.match(/доступна\s+организация:\s*([^.\n]+)/iu);
|
||||
if (singleMatch) {
|
||||
const value = normalizeOrganizationScopeValue(singleMatch[1]);
|
||||
if (value) {
|
||||
extracted.push(value);
|
||||
}
|
||||
}
|
||||
const multiMatch = source.match(/доступны\s+организац(?:ии|ия)\s*(?:\(\d+\))?:\s*([^.\n]+)/iu);
|
||||
if (multiMatch) {
|
||||
const parts = String(multiMatch[1] ?? "")
|
||||
.split(",")
|
||||
.map((item) => normalizeOrganizationScopeValue(item))
|
||||
.filter(Boolean);
|
||||
extracted.push(...parts);
|
||||
}
|
||||
return Array.from(new Set(extracted));
|
||||
}
|
||||
function mergeKnownOrganizations(values) {
|
||||
const dedup = new Map();
|
||||
for (const raw of Array.isArray(values) ? values : []) {
|
||||
const normalized = normalizeOrganizationScopeValue(raw);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
const key = normalizeOrganizationScopeSearchText(normalized);
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
if (!dedup.has(key)) {
|
||||
dedup.set(key, normalized);
|
||||
}
|
||||
}
|
||||
return Array.from(dedup.values()).slice(0, 20);
|
||||
}
|
||||
function extractKnownOrganizationsFromHistory(items) {
|
||||
const collected = [];
|
||||
for (let index = (Array.isArray(items) ? items.length : 0) - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (!item || item.role !== "assistant") {
|
||||
continue;
|
||||
}
|
||||
const debug = item.debug && typeof item.debug === "object" ? item.debug : null;
|
||||
if (debug) {
|
||||
const directFromProbe = Array.isArray(debug.living_chat_data_scope_probe_organizations)
|
||||
? debug.living_chat_data_scope_probe_organizations
|
||||
: [];
|
||||
const knownFromDebug = Array.isArray(debug.assistant_known_organizations)
|
||||
? debug.assistant_known_organizations
|
||||
: [];
|
||||
if (directFromProbe.length > 0 || knownFromDebug.length > 0) {
|
||||
collected.push(...directFromProbe, ...knownFromDebug);
|
||||
}
|
||||
}
|
||||
const parsedFromText = parseOrganizationsFromDataScopeAssistantText(item.text);
|
||||
if (parsedFromText.length > 0) {
|
||||
collected.push(...parsedFromText);
|
||||
}
|
||||
if (collected.length >= 20) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return mergeKnownOrganizations(collected);
|
||||
}
|
||||
function findLastAssistantActiveOrganization(items) {
|
||||
for (let index = (Array.isArray(items) ? items.length : 0) - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") {
|
||||
continue;
|
||||
}
|
||||
const direct = normalizeOrganizationScopeValue(item.debug.assistant_active_organization);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const selected = normalizeOrganizationScopeValue(item.debug.living_chat_selected_organization);
|
||||
if (selected) {
|
||||
return selected;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function resolveOrganizationSelectionFromMessage(userMessage, knownOrganizations) {
|
||||
const known = mergeKnownOrganizations(knownOrganizations);
|
||||
if (!userMessage || known.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const messageNorm = normalizeOrganizationScopeSearchText(userMessage);
|
||||
if (!messageNorm) {
|
||||
return null;
|
||||
}
|
||||
const scored = known
|
||||
.map((organization) => ({
|
||||
organization,
|
||||
score: scoreOrganizationMentionInMessage(messageNorm, organization)
|
||||
}))
|
||||
.filter((item) => item.score > 0)
|
||||
.sort((a, b) => b.score - a.score || a.organization.length - b.organization.length);
|
||||
if (scored.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const best = scored[0];
|
||||
const second = scored[1];
|
||||
if (best.score < 90) {
|
||||
return null;
|
||||
}
|
||||
if (second && second.score === best.score) {
|
||||
return null;
|
||||
}
|
||||
return best.organization;
|
||||
}
|
||||
function resolveSessionOrganizationScopeContext(userMessage, items) {
|
||||
const knownOrganizations = extractKnownOrganizationsFromHistory(items);
|
||||
const selectedOrganization = resolveOrganizationSelectionFromMessage(userMessage, knownOrganizations);
|
||||
const lastActiveOrganization = findLastAssistantActiveOrganization(items);
|
||||
const activeOrganization = selectedOrganization ?? normalizeOrganizationScopeValue(lastActiveOrganization);
|
||||
return {
|
||||
knownOrganizations,
|
||||
selectedOrganization,
|
||||
activeOrganization
|
||||
};
|
||||
}
|
||||
function mergeFollowupContextWithOrganizationScope(followupContext, organization) {
|
||||
const normalizedOrganization = normalizeOrganizationScopeValue(organization);
|
||||
const base = followupContext && typeof followupContext === "object" ? { ...followupContext } : {};
|
||||
if (!normalizedOrganization) {
|
||||
return followupContext && typeof followupContext === "object" ? base : null;
|
||||
}
|
||||
const previousFilters = base.previous_filters && typeof base.previous_filters === "object"
|
||||
? { ...base.previous_filters }
|
||||
: {};
|
||||
if (!toNonEmptyString(previousFilters.organization)) {
|
||||
previousFilters.organization = normalizedOrganization;
|
||||
}
|
||||
base.previous_filters = previousFilters;
|
||||
return base;
|
||||
}
|
||||
export function resolveSessionOrganizationScopeContextForTests(userMessage, items) {
|
||||
return resolveSessionOrganizationScopeContext(userMessage, items);
|
||||
}
|
||||
function normalizeGuidValue(value) {
|
||||
const source = normalizeScopeLabel(value);
|
||||
if (!source) {
|
||||
@@ -4193,6 +4583,26 @@ function buildAssistantDataScopeContractReply(scopeProbe = null) {
|
||||
"Если подключено несколько баз, для автосписка нужен MCP-метод метаданных (перечень баз/организаций); без него можно анализировать только активный контур запросов."
|
||||
].join(" ");
|
||||
}
|
||||
function buildAssistantDataScopeSelectionReply(organization) {
|
||||
const selected = normalizeOrganizationScopeValue(organization) ?? String(organization ?? "").trim();
|
||||
return [
|
||||
`Отлично, фиксирую рабочую организацию: ${selected}.`,
|
||||
"Дальше буду держать этот контур как активный, пока вы не переключите организацию."
|
||||
].join(" ");
|
||||
}
|
||||
function buildAssistantOrganizationFactBoundaryReply(organization) {
|
||||
const selected = normalizeOrganizationScopeValue(organization) ?? String(organization ?? "").trim();
|
||||
if (selected) {
|
||||
return [
|
||||
`По организации ${selected} не буду называть дату/возраст без live-подтвержденного источника.`,
|
||||
"Если нужно, запрошу факт из 1С и верну только подтвержденный ответ."
|
||||
].join(" ");
|
||||
}
|
||||
return [
|
||||
"Не буду называть дату/возраст организации без live-подтвержденного источника.",
|
||||
"Сначала получу факт из 1С, потом дам точный ответ."
|
||||
].join(" ");
|
||||
}
|
||||
function buildAssistantOperationalBoundaryReply() {
|
||||
return [
|
||||
"Понимаю, что ситуация срочная.",
|
||||
@@ -4256,6 +4666,45 @@ function applyLivingChatScriptGuard(chatText, userMessage) {
|
||||
reason: "unexpected_cjk_fragment_fallback"
|
||||
};
|
||||
}
|
||||
function applyLivingChatGroundingGuard(input) {
|
||||
const userMessage = String(input?.userMessage ?? "");
|
||||
const chatText = String(input?.chatText ?? "").trim();
|
||||
const organization = toNonEmptyString(input?.organization);
|
||||
if (!chatText) {
|
||||
return {
|
||||
text: chatText,
|
||||
applied: false,
|
||||
reason: null
|
||||
};
|
||||
}
|
||||
if (!hasOrganizationFactLookupSignal(userMessage)) {
|
||||
return {
|
||||
text: chatText,
|
||||
applied: false,
|
||||
reason: null
|
||||
};
|
||||
}
|
||||
if (/(?:не\s+могу|не\s+вижу|после\s+проверки|live|подтвержден)/i.test(chatText)) {
|
||||
return {
|
||||
text: chatText,
|
||||
applied: false,
|
||||
reason: null
|
||||
};
|
||||
}
|
||||
const hasSpecificUnverifiedFact = /(?:\b\d{1,2}[./-]\d{1,2}[./-](?:\d{2}|\d{4})\b|\b(?:19|20)\d{2}\b|\b\d+\s+лет\b)/i.test(chatText);
|
||||
if (!hasSpecificUnverifiedFact) {
|
||||
return {
|
||||
text: chatText,
|
||||
applied: false,
|
||||
reason: null
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: buildAssistantOrganizationFactBoundaryReply(organization),
|
||||
applied: true,
|
||||
reason: "organization_fact_without_live_source_blocked"
|
||||
};
|
||||
}
|
||||
export function resolveLivingAssistantModeDecision(input) {
|
||||
const userMessage = String(input?.userMessage ?? "");
|
||||
if (input?.addressLaneTriggered) {
|
||||
@@ -4334,7 +4783,9 @@ export class AssistantService {
|
||||
async handleMessage(payload) {
|
||||
const session = this.sessions.ensureSession(payload.session_id);
|
||||
const sessionId = session.session_id;
|
||||
const userMessage = String(payload.user_message ?? payload.message ?? "").trim();
|
||||
const userMessageRaw = String(payload.user_message ?? payload.message ?? "").trim();
|
||||
const repairedUserMessage = compactWhitespace(repairAddressMojibake(userMessageRaw));
|
||||
const userMessage = repairedUserMessage || userMessageRaw;
|
||||
const userItem = {
|
||||
message_id: `msg-${(0, nanoid_1.nanoid)(10)}`,
|
||||
session_id: sessionId,
|
||||
@@ -4346,6 +4797,7 @@ export class AssistantService {
|
||||
debug: null
|
||||
};
|
||||
this.sessions.appendItem(sessionId, userItem);
|
||||
const sessionOrganizationScope = resolveSessionOrganizationScopeContext(userMessage, session.items);
|
||||
const finalizeAddressLaneResponse = (addressLane, effectiveAddressUserMessage, carryoverMeta = null, llmPreDecomposeMeta = null) => {
|
||||
const safeAddressReply = sanitizeOutgoingAssistantText(addressLane.reply_text);
|
||||
const debug = buildAddressDebugPayload(addressLane.debug, llmPreDecomposeMeta);
|
||||
@@ -4353,6 +4805,15 @@ export class AssistantService {
|
||||
if (followupOffer) {
|
||||
debug.address_followup_offer = followupOffer;
|
||||
}
|
||||
const debugKnownOrganizations = mergeKnownOrganizations(sessionOrganizationScope.knownOrganizations);
|
||||
const debugActiveOrganization = toNonEmptyString(debug?.extracted_filters?.organization) ??
|
||||
toNonEmptyString(sessionOrganizationScope.activeOrganization);
|
||||
if (debugKnownOrganizations.length > 0) {
|
||||
debug.assistant_known_organizations = debugKnownOrganizations;
|
||||
}
|
||||
if (debugActiveOrganization) {
|
||||
debug.assistant_active_organization = debugActiveOrganization;
|
||||
}
|
||||
const assistantItem = {
|
||||
message_id: `msg-${(0, nanoid_1.nanoid)(10)}`,
|
||||
session_id: sessionId,
|
||||
@@ -4458,6 +4919,11 @@ export class AssistantService {
|
||||
let livingChatSource = "llm_chat";
|
||||
let livingChatScriptGuardApplied = false;
|
||||
let livingChatScriptGuardReason = null;
|
||||
let livingChatGroundingGuardApplied = false;
|
||||
let livingChatGroundingGuardReason = null;
|
||||
let knownOrganizations = mergeKnownOrganizations(sessionOrganizationScope.knownOrganizations);
|
||||
let selectedOrganization = toNonEmptyString(sessionOrganizationScope.selectedOrganization);
|
||||
let activeOrganization = toNonEmptyString(sessionOrganizationScope.activeOrganization);
|
||||
if (capabilityMetaQuery && (destructiveSignal || dangerSignal)) {
|
||||
chatText = buildAssistantSafetyRefusalReply();
|
||||
livingChatSource = "deterministic_safety_refusal";
|
||||
@@ -4465,10 +4931,35 @@ export class AssistantService {
|
||||
else if (dataScopeMetaQuery) {
|
||||
dataScopeProbe = await resolveAssistantDataScopeProbe();
|
||||
chatText = buildAssistantDataScopeContractReply(dataScopeProbe);
|
||||
knownOrganizations = mergeKnownOrganizations([
|
||||
...knownOrganizations,
|
||||
...(Array.isArray(dataScopeProbe?.organizations) ? dataScopeProbe.organizations : [])
|
||||
]);
|
||||
if (!activeOrganization && knownOrganizations.length === 1) {
|
||||
activeOrganization = knownOrganizations[0];
|
||||
}
|
||||
livingChatSource = dataScopeProbe?.status === "resolved"
|
||||
? "deterministic_data_scope_contract_live"
|
||||
: "deterministic_data_scope_contract";
|
||||
}
|
||||
else if ((selectedOrganization || activeOrganization) && hasOrganizationFactLookupSignal(userMessage)) {
|
||||
const scopedOrganization = selectedOrganization ?? activeOrganization ?? null;
|
||||
chatText = buildAssistantOrganizationFactBoundaryReply(scopedOrganization);
|
||||
activeOrganization = scopedOrganization ?? activeOrganization;
|
||||
livingChatSource = "deterministic_organization_fact_boundary";
|
||||
}
|
||||
else if ((selectedOrganization || activeOrganization) && hasOrganizationFactFollowupSignal(userMessage, session.items)) {
|
||||
const scopedOrganization = selectedOrganization ?? activeOrganization ?? null;
|
||||
chatText = buildAssistantOrganizationFactBoundaryReply(scopedOrganization);
|
||||
activeOrganization = scopedOrganization ?? activeOrganization;
|
||||
livingChatSource = "deterministic_organization_fact_boundary_followup";
|
||||
}
|
||||
else if (!capabilityMetaQuery && shouldEmitOrganizationSelectionReply(userMessage, selectedOrganization ?? activeOrganization)) {
|
||||
const scopedOrganization = selectedOrganization ?? activeOrganization ?? null;
|
||||
chatText = buildAssistantDataScopeSelectionReply(scopedOrganization);
|
||||
activeOrganization = scopedOrganization ?? activeOrganization;
|
||||
livingChatSource = "deterministic_data_scope_selection_contract";
|
||||
}
|
||||
else if (capabilityMetaQuery && operationalSignal && !hasAssistantCapabilityQuestionSignal(userMessage)) {
|
||||
chatText = buildAssistantOperationalBoundaryReply();
|
||||
livingChatSource = "deterministic_operational_boundary";
|
||||
@@ -4508,6 +4999,17 @@ export class AssistantService {
|
||||
livingChatScriptGuardReason = scriptGuard.reason;
|
||||
livingChatSource = "llm_chat_script_guard";
|
||||
}
|
||||
const groundingGuard = applyLivingChatGroundingGuard({
|
||||
userMessage,
|
||||
chatText,
|
||||
organization: activeOrganization ?? selectedOrganization ?? null
|
||||
});
|
||||
chatText = groundingGuard.text;
|
||||
if (groundingGuard.applied) {
|
||||
livingChatGroundingGuardApplied = true;
|
||||
livingChatGroundingGuardReason = groundingGuard.reason;
|
||||
livingChatSource = "llm_chat_grounding_guard";
|
||||
}
|
||||
}
|
||||
if (!chatText) {
|
||||
return null;
|
||||
@@ -4525,12 +5027,20 @@ export class AssistantService {
|
||||
living_chat_response_source: livingChatSource,
|
||||
living_chat_script_guard_applied: livingChatScriptGuardApplied,
|
||||
living_chat_script_guard_reason: livingChatScriptGuardReason,
|
||||
living_chat_grounding_guard_applied: livingChatGroundingGuardApplied,
|
||||
living_chat_grounding_guard_reason: livingChatGroundingGuardReason,
|
||||
living_chat_data_scope_probe_status: dataScopeProbe?.status ?? null,
|
||||
living_chat_data_scope_probe_channel: dataScopeProbe?.channel ?? null,
|
||||
living_chat_data_scope_probe_org_count: Array.isArray(dataScopeProbe?.organizations)
|
||||
? dataScopeProbe.organizations.length
|
||||
: 0,
|
||||
living_chat_data_scope_probe_organizations: Array.isArray(dataScopeProbe?.organizations)
|
||||
? mergeKnownOrganizations(dataScopeProbe.organizations)
|
||||
: [],
|
||||
living_chat_data_scope_probe_error: dataScopeProbe?.error ?? null,
|
||||
living_chat_selected_organization: selectedOrganization ?? null,
|
||||
assistant_known_organizations: knownOrganizations,
|
||||
assistant_active_organization: activeOrganization ?? null,
|
||||
address_llm_predecompose_attempted: Boolean(addressRuntimeMeta?.attempted),
|
||||
address_llm_predecompose_applied: Boolean(addressRuntimeMeta?.applied),
|
||||
address_llm_predecompose_reason: addressRuntimeMeta?.reason ?? null,
|
||||
@@ -4716,9 +5226,10 @@ export class AssistantService {
|
||||
};
|
||||
};
|
||||
const runAddressLaneAttempt = async (messageUsed, carryMeta) => {
|
||||
if (carryMeta?.followupContext) {
|
||||
const scopedFollowupContext = mergeFollowupContextWithOrganizationScope(carryMeta?.followupContext ?? null, sessionOrganizationScope.activeOrganization);
|
||||
if (scopedFollowupContext) {
|
||||
return this.addressQueryService.tryHandle(messageUsed, {
|
||||
followupContext: carryMeta.followupContext
|
||||
followupContext: scopedFollowupContext
|
||||
});
|
||||
}
|
||||
return this.addressQueryService.tryHandle(messageUsed);
|
||||
|
||||
Reference in New Issue
Block a user