Initial import NDC_1C

This commit is contained in:
2026-03-26 10:38:25 +03:00
commit a162d77ef7
2943 changed files with 3615871 additions and 0 deletions
@@ -0,0 +1,125 @@
import { useEffect, useRef } from "react";
import type { AssistantConversationItem } from "../state/types";
import { JsonView } from "./JsonView";
import { PanelFrame } from "./PanelFrame";
interface AssistantPanelProps {
sessionId: string;
conversation: AssistantConversationItem[];
inputValue: string;
onInputChange: (value: string) => void;
periodHint: string;
onPeriodHintChange: (value: string) => void;
businessContext: string;
onBusinessContextChange: (value: string) => void;
useMock: boolean;
onUseMockChange: (value: boolean) => void;
onSend: () => Promise<void> | void;
onClear: () => void;
busy: boolean;
statusText: string;
errorMessage: string;
}
function roleLabel(role: AssistantConversationItem["role"]): string {
return role === "assistant" ? "Ассистент" : "Вы";
}
function shortTime(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
return iso;
}
return date.toLocaleTimeString("ru-RU");
}
export function AssistantPanel({
sessionId,
conversation,
inputValue,
onInputChange,
periodHint,
onPeriodHintChange,
businessContext,
onBusinessContextChange,
useMock,
onUseMockChange,
onSend,
onClear,
busy,
statusText,
errorMessage
}: AssistantPanelProps) {
const listRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, [conversation, statusText]);
return (
<PanelFrame
title="Режим ассистента"
subtitle="Диалоговый слой поверх normalizer, маршрутизации и factual retrieval."
actions={<span className="status-chip">{sessionId ? `session: ${sessionId}` : "новая сессия"}</span>}
>
<div ref={listRef} className="assistant-chat-list">
{conversation.length === 0 ? (
<div className="assistant-empty muted">Диалог пуст. Отправьте первый вопрос, чтобы запустить контур ассистента.</div>
) : null}
{conversation.map((item) => (
<article key={item.message_id} className={`assistant-msg ${item.role}`}>
<header className="assistant-msg-head">
<strong>{roleLabel(item.role)}</strong>
<span>{shortTime(item.created_at)}</span>
</header>
<div className="assistant-msg-body">{item.text}</div>
{item.role === "assistant" && item.debug ? (
<details className="assistant-debug">
<summary>Показать технический разбор</summary>
<JsonView value={item.debug} />
</details>
) : null}
</article>
))}
</div>
<div className="assistant-compose">
<div className="grid-two">
<label>
Подсказка по периоду
<input value={periodHint} onChange={(event) => onPeriodHintChange(event.target.value)} />
</label>
<label>
Бизнес-контекст
<input value={businessContext} onChange={(event) => onBusinessContextChange(event.target.value)} />
</label>
</div>
<label className="full-width">
Сообщение
<textarea
value={inputValue}
onChange={(event) => onInputChange(event.target.value)}
rows={4}
placeholder="Введите вопрос к данным компании..."
/>
</label>
<div className="button-row">
<label className="checkbox-row">
<input type="checkbox" checked={useMock} onChange={(event) => onUseMockChange(event.target.checked)} />
Mock-режим
</label>
<button type="button" onClick={() => onSend()} disabled={busy || !inputValue.trim()}>
{busy ? "Выполняю..." : "Отправить"}
</button>
<button type="button" onClick={() => onClear()} disabled={busy && conversation.length === 0}>
Сбросить сессию
</button>
</div>
{statusText ? <p className="diff-summary">{statusText}</p> : null}
{errorMessage ? <p className="error-text">{errorMessage}</p> : null}
</div>
</PanelFrame>
);
}
@@ -0,0 +1,73 @@
import { PanelFrame } from "./PanelFrame";
import type { ConnectionState } from "../state/types";
interface ConnectionPanelProps {
value: ConnectionState;
onChange: (next: ConnectionState) => void;
onTestConnection: () => Promise<void> | void;
onSaveLocalConfig: () => void;
lastStatus: string;
busy: boolean;
}
export function ConnectionPanel({
value,
onChange,
onTestConnection,
onSaveLocalConfig,
lastStatus,
busy
}: ConnectionPanelProps) {
return (
<PanelFrame
title="Подключение OpenAI"
subtitle="Ключ живет только в памяти сессии (не пишется в localStorage)."
actions={<span className="status-chip">{lastStatus || "Статус: не проверено"}</span>}
>
<div className="grid-two">
<label>
OpenAI API Key
<input
type="password"
value={value.apiKey}
onChange={(event) => onChange({ ...value, apiKey: event.target.value })}
placeholder="sk-..."
/>
</label>
<label>
Model ID
<input value={value.model} onChange={(event) => onChange({ ...value, model: event.target.value })} />
</label>
<label>
Base URL
<input value={value.baseUrl} onChange={(event) => onChange({ ...value, baseUrl: event.target.value })} />
</label>
<label>
Temperature
<input
type="number"
step="0.1"
value={value.temperature}
onChange={(event) => onChange({ ...value, temperature: Number(event.target.value) })}
/>
</label>
<label>
Max output tokens
<input
type="number"
value={value.maxOutputTokens}
onChange={(event) => onChange({ ...value, maxOutputTokens: Number(event.target.value) })}
/>
</label>
</div>
<div className="button-row">
<button type="button" onClick={() => onSaveLocalConfig()}>
Сохранить локальную конфигурацию
</button>
<button type="button" onClick={() => onTestConnection()} disabled={busy}>
{busy ? "Проверяем..." : "Проверить подключение"}
</button>
</div>
</PanelFrame>
);
}
@@ -0,0 +1,39 @@
import { PanelFrame } from "./PanelFrame";
import type { HistoryItem } from "../state/types";
interface HistoryPanelProps {
items: HistoryItem[];
onRefresh: () => Promise<void> | void;
onOpenTrace: (traceId: string) => Promise<void> | void;
}
export function HistoryPanel({ items, onRefresh, onOpenTrace }: HistoryPanelProps) {
return (
<PanelFrame
title="История нормализаций"
subtitle="Короткий вопрос, confidence, route hint и статус валидации."
actions={
<button type="button" onClick={() => onRefresh()}>
Обновить
</button>
}
>
<div className="history-list">
{items.length === 0 ? <p className="muted">История пока пустая.</p> : null}
{items.map((item) => (
<button key={item.trace_id} type="button" className="history-item" onClick={() => onOpenTrace(item.trace_id)}>
<div className="history-row">
<strong>{item.route_hint ?? "route: n/a"}</strong>
<span>{item.validation_passed ? "schema: ok" : "schema: fail"}</span>
</div>
<p>{item.question_short}</p>
<div className="history-row">
<span>{item.model}</span>
<span>{new Date(item.timestamp).toLocaleString("ru-RU")}</span>
</div>
</button>
))}
</div>
</PanelFrame>
);
}
@@ -0,0 +1,7 @@
interface JsonViewProps {
value: unknown;
}
export function JsonView({ value }: JsonViewProps) {
return <pre className="json-view">{JSON.stringify(value ?? {}, null, 2)}</pre>;
}
@@ -0,0 +1,60 @@
import { PanelFrame } from "./PanelFrame";
import type { NormalizeResultState } from "../state/types";
interface MetricsPanelProps {
result: NormalizeResultState | null;
}
function metric(value: string | number | boolean | null | undefined): string {
if (value === null || value === undefined || value === "") return "—";
return String(value);
}
export function MetricsPanel({ result }: MetricsPanelProps) {
return (
<PanelFrame title="Runtime метрики" subtitle="trace_id, токены, latency и статус валидации.">
<div className="metrics-grid">
<div>
<span>trace_id</span>
<strong>{metric(result?.trace_id)}</strong>
</div>
<div>
<span>request_started_at</span>
<strong>{metric(result ? new Date(Date.now() - result.latency_ms).toISOString() : null)}</strong>
</div>
<div>
<span>request_finished_at</span>
<strong>{metric(result ? new Date().toISOString() : null)}</strong>
</div>
<div>
<span>latency_ms</span>
<strong>{metric(result?.latency_ms)}</strong>
</div>
<div>
<span>input_tokens</span>
<strong>{metric(result?.usage?.input_tokens)}</strong>
</div>
<div>
<span>output_tokens</span>
<strong>{metric(result?.usage?.output_tokens)}</strong>
</div>
<div>
<span>total_tokens</span>
<strong>{metric(result?.usage?.total_tokens)}</strong>
</div>
<div>
<span>validation_status</span>
<strong>{result?.validation?.passed ? "passed" : "failed"}</strong>
</div>
<div>
<span>prompt_version</span>
<strong>{metric(result?.prompt_version)}</strong>
</div>
<div>
<span>schema_version</span>
<strong>{metric(result?.schema_version)}</strong>
</div>
</div>
</PanelFrame>
);
}
@@ -0,0 +1,87 @@
import { PanelFrame } from "./PanelFrame";
import { JsonView } from "./JsonView";
import type { NormalizeResultState, TabKey } from "../state/types";
interface OutputPanelProps {
tab: TabKey;
onTabChange: (tab: TabKey) => void;
result: NormalizeResultState | null;
appLogs: string[];
}
const TAB_LABELS: Record<TabKey, string> = {
normalized: "Normalized JSON",
fragments: "Fragment View",
scope: "Scope View",
flags: "Flags View",
route: "Route Simulation",
raw: "Raw model output",
validation: "Validation",
logs: "Logs"
};
function asObject(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
}
export function OutputPanel({ tab, onTabChange, result, appLogs }: OutputPanelProps) {
const tabs: TabKey[] = ["normalized", "fragments", "scope", "flags", "route", "raw", "validation", "logs"];
const normalized = asObject(result?.normalized);
const schemaVersion = String(normalized?.schema_version ?? "");
const isV2 =
schemaVersion === "normalized_query_v2" ||
schemaVersion === "normalized_query_v2_0_1" ||
schemaVersion === "normalized_query_v2_0_2";
const fragmentsView = isV2
? {
fragments: normalized?.fragments ?? [],
discarded_fragments: normalized?.discarded_fragments ?? []
}
: { note: "Fragment View доступен для normalized_query_v2." };
const scopeView = isV2
? {
message_in_scope: normalized?.message_in_scope ?? null,
scope_confidence: normalized?.scope_confidence ?? null,
contains_multiple_tasks: normalized?.contains_multiple_tasks ?? null,
global_notes: normalized?.global_notes ?? null
}
: { note: "Scope View доступен для normalized_query_v2." };
const flagsView = isV2
? Array.isArray(normalized?.fragments)
? (normalized?.fragments as Array<Record<string, unknown>>).map((fragment) => ({
fragment_id: fragment.fragment_id ?? null,
domain_relevance: fragment.domain_relevance ?? null,
candidate_labels: fragment.candidate_labels ?? [],
execution_readiness: fragment.execution_readiness ?? null,
clarification_reason: fragment.clarification_reason ?? null,
soft_assumption_used: fragment.soft_assumption_used ?? [],
route_status: fragment.route_status ?? null,
no_route_reason: fragment.no_route_reason ?? null,
flags: fragment.flags ?? {}
}))
: []
: { note: "Flags View доступен для normalized_query_v2." };
return (
<PanelFrame title="Выходные данные" subtitle="Structured output и диагностические вкладки.">
<div className="tab-row">
{tabs.map((item) => (
<button key={item} type="button" className={tab === item ? "tab active" : "tab"} onClick={() => onTabChange(item)}>
{TAB_LABELS[item]}
</button>
))}
</div>
{tab === "normalized" ? <JsonView value={result?.normalized ?? { note: "Нет данных." }} /> : null}
{tab === "fragments" ? <JsonView value={fragmentsView} /> : null}
{tab === "scope" ? <JsonView value={scopeView} /> : null}
{tab === "flags" ? <JsonView value={flagsView} /> : null}
{tab === "route" ? <JsonView value={result?.route_hint_summary ?? { note: "Нет данных." }} /> : null}
{tab === "raw" ? <JsonView value={result?.raw_model_output ?? { note: "Нет данных." }} /> : null}
{tab === "validation" ? <JsonView value={result?.validation ?? { note: "Нет данных." }} /> : null}
{tab === "logs" ? <JsonView value={appLogs} /> : null}
</PanelFrame>
);
}
@@ -0,0 +1,22 @@
import type { PropsWithChildren, ReactNode } from "react";
interface PanelFrameProps {
title: string;
subtitle?: string;
actions?: ReactNode;
}
export function PanelFrame({ title, subtitle, actions, children }: PropsWithChildren<PanelFrameProps>) {
return (
<section className="panel-frame">
<header className="panel-header">
<div>
<h2>{title}</h2>
{subtitle ? <p>{subtitle}</p> : null}
</div>
{actions ? <div className="panel-actions">{actions}</div> : null}
</header>
<div className="panel-body">{children}</div>
</section>
);
}
@@ -0,0 +1,106 @@
import { PanelFrame } from "./PanelFrame";
import type { PromptState } from "../state/types";
interface PromptPresetListItem {
id: string;
name: string;
prompt_version: string;
systemPrompt: string;
developerPrompt: string;
domainPrompt: string;
schemaNotes?: string;
fewShotExamples?: string;
}
interface PromptPanelProps {
value: PromptState;
onChange: (next: PromptState) => void;
presets: PromptPresetListItem[];
selectedPresetId: string;
onSelectPreset: (id: string) => void;
onLoadPreset: () => void;
onSavePreset: () => void;
onResetDefaults: () => void;
onDiffPrevious: () => void;
presetName: string;
onPresetNameChange: (name: string) => void;
diffSummary: string;
}
export function PromptPanel({
value,
onChange,
presets,
selectedPresetId,
onSelectPreset,
onLoadPreset,
onSavePreset,
onResetDefaults,
onDiffPrevious,
presetName,
onPresetNameChange,
diffSummary
}: PromptPanelProps) {
return (
<PanelFrame title="Prompt Manager" subtitle="Системный, developer и domain уровни управляются отдельно.">
<div className="grid-two">
<label>
Системный prompt
<textarea
value={value.systemPrompt}
onChange={(event) => onChange({ ...value, systemPrompt: event.target.value })}
rows={6}
/>
</label>
<label>
Developer / Instruction prompt
<textarea
value={value.developerPrompt}
onChange={(event) => onChange({ ...value, developerPrompt: event.target.value })}
rows={6}
/>
</label>
<label>
Domain prompt
<textarea value={value.domainPrompt} onChange={(event) => onChange({ ...value, domainPrompt: event.target.value })} rows={6} />
</label>
<label>
Schema notes
<textarea value={value.schemaNotes} onChange={(event) => onChange({ ...value, schemaNotes: event.target.value })} rows={6} />
</label>
<label className="full-width">
Few-shot examples
<textarea
value={value.fewShotExamples}
onChange={(event) => onChange({ ...value, fewShotExamples: event.target.value })}
rows={8}
/>
</label>
</div>
<div className="button-row">
<select value={selectedPresetId} onChange={(event) => onSelectPreset(event.target.value)}>
<option value="">Выберите preset...</option>
{presets.map((preset) => (
<option value={preset.id} key={preset.id}>
{preset.name}
</option>
))}
</select>
<button type="button" onClick={() => onLoadPreset()}>
Загрузить preset
</button>
<input value={presetName} onChange={(event) => onPresetNameChange(event.target.value)} placeholder="Имя для сохранения" />
<button type="button" onClick={() => onSavePreset()}>
Сохранить preset
</button>
<button type="button" onClick={() => onDiffPrevious()}>
Diff с предыдущим
</button>
<button type="button" onClick={() => onResetDefaults()}>
Сбросить к default
</button>
</div>
{diffSummary ? <p className="diff-summary">{diffSummary}</p> : null}
</PanelFrame>
);
}
@@ -0,0 +1,78 @@
import { PanelFrame } from "./PanelFrame";
import type { QueryState } from "../state/types";
interface QueryPanelProps {
value: QueryState;
onChange: (next: QueryState) => void;
onApplyBatchFormat: () => void;
onNormalize: (saveCase: boolean) => Promise<void> | void;
busy: boolean;
useMock: boolean;
onUseMockChange: (next: boolean) => void;
errorMessage: string;
}
export function QueryPanel({
value,
onChange,
onApplyBatchFormat,
onNormalize,
busy,
useMock,
onUseMockChange,
errorMessage
}: QueryPanelProps) {
return (
<PanelFrame title="Запрос пользователя" subtitle="NDC semantic front-end: нормализуем, но не отвечаем за бухгалтерскую суть.">
<div className="grid-two">
<label className="full-width">
Raw user question
<textarea
value={value.userQuestion}
onChange={(event) => onChange({ ...value, userQuestion: event.target.value })}
rows={6}
placeholder="Например: По каким покупателям у нас на конец июня висят отгрузки без оплаты..."
/>
</label>
<label className="full-width">
Batch queries (`;` separator)
<textarea
value={value.batchQuestionsRaw}
onChange={(event) => onChange({ ...value, batchQuestionsRaw: event.target.value })}
onBlur={() => onApplyBatchFormat()}
rows={8}
placeholder="Вопрос 1; Вопрос 2; Вопрос 3"
/>
</label>
<label>
Optional period context
<input value={value.periodHint} onChange={(event) => onChange({ ...value, periodHint: event.target.value })} />
</label>
<label>
Optional business context
<input value={value.businessContext} onChange={(event) => onChange({ ...value, businessContext: event.target.value })} />
</label>
<label>
Optional expected route (eval)
<input value={value.expectedRoute} onChange={(event) => onChange({ ...value, expectedRoute: event.target.value })} />
</label>
</div>
<div className="button-row">
<label className="checkbox-row">
<input type="checkbox" checked={useMock} onChange={(event) => onUseMockChange(event.target.checked)} />
Mock-режим (без вызова OpenAI)
</label>
<button type="button" onClick={() => onApplyBatchFormat()} disabled={busy || !value.batchQuestionsRaw.trim()}>
Применить `;` в переносы
</button>
<button type="button" onClick={() => onNormalize(false)} disabled={busy || !value.userQuestion.trim()}>
{busy ? "Нормализуем..." : "Normalize"}
</button>
<button type="button" onClick={() => onNormalize(true)} disabled={busy || !value.userQuestion.trim()}>
{busy ? "Сохраняем..." : "Normalize + Save as test case"}
</button>
</div>
{errorMessage ? <p className="error-text">{errorMessage}</p> : null}
</PanelFrame>
);
}
@@ -0,0 +1,86 @@
import { PanelFrame } from "./PanelFrame";
import type { RuntimeRun } from "../state/types";
import { JsonView } from "./JsonView";
interface RuntimePanelProps {
runs: RuntimeRun[];
selectedRunId: string;
onSelectRun: (runId: string) => void;
onStartRun: () => Promise<void> | void;
onFinishRun: () => Promise<void> | void;
onRefreshRuns: () => Promise<void> | void;
onRunEval: () => Promise<void> | void;
onCopyEvalReport: () => Promise<void> | void;
evalBusy: boolean;
traceItems: unknown[];
evalReport: unknown;
}
export function RuntimePanel({
runs,
selectedRunId,
onSelectRun,
onStartRun,
onFinishRun,
onRefreshRuns,
onRunEval,
onCopyEvalReport,
evalBusy,
traceItems,
evalReport
}: RuntimePanelProps) {
return (
<PanelFrame
title="NDC Run Monitor"
subtitle="Важно: кнопка Запустить run создает только run-сущность. Кнопка eval запускает batch-проверку normalizer v2.0.2."
>
<div className="button-row">
<button type="button" onClick={() => onStartRun()}>
Запустить run
</button>
<button type="button" onClick={() => onFinishRun()} disabled={!selectedRunId}>
Завершить выбранный run
</button>
<button type="button" onClick={() => onRefreshRuns()}>
Обновить runs
</button>
<button type="button" onClick={() => onRunEval()} disabled={evalBusy}>
{evalBusy ? "Идет eval v2.0.2..." : "Запустить eval v2.0.2"}
</button>
</div>
<div className="runtime-grid">
<div className="runtime-runs">
{runs.map((run) => (
<button
key={run.runId}
type="button"
className={selectedRunId === run.runId ? "history-item selected" : "history-item"}
onClick={() => onSelectRun(run.runId)}
>
<div className="history-row">
<strong>{run.status}</strong>
<span>{run.runId}</span>
</div>
<div className="history-row">
<span>{run.sessionId}</span>
<span>{new Date(run.updatedAt).toLocaleString("ru-RU")}</span>
</div>
</button>
))}
{runs.length === 0 ? <p className="muted">Нет активных запусков.</p> : null}
</div>
<div>
<h3>Trace выбранного run</h3>
<JsonView value={traceItems} />
<div className="eval-report-wrap">
<h3 style={{ marginTop: 12 }}>Отчет eval</h3>
<JsonView value={evalReport ?? { note: "Eval пока не запускался" }} />
<button type="button" className="copy-cube-button" title="Скопировать отчет eval" onClick={() => onCopyEvalReport()}>
⧉
</button>
</div>
</div>
</div>
</PanelFrame>
);
}