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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NDC AI Normalizer Playground</title>
<script type="module" crossorigin src="/assets/index-B-bUXClb.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D1qe5c2Q.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NDC AI Normalizer Playground</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "llm-normalizer-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5174",
"build": "tsc -b && vite build",
"preview": "vite preview --host 0.0.0.0 --port 4174"
},
"dependencies": {
"llm-normalizer-workspace": "file:..",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.24",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^5.0.4",
"typescript": "^5.9.3",
"vite": "^7.1.10"
}
}
+581
View File
@@ -0,0 +1,581 @@
import { useEffect, useState } from "react";
import { apiClient } from "./api/client";
import { AssistantPanel } from "./components/AssistantPanel";
import { ConnectionPanel } from "./components/ConnectionPanel";
import { HistoryPanel } from "./components/HistoryPanel";
import { MetricsPanel } from "./components/MetricsPanel";
import { OutputPanel } from "./components/OutputPanel";
import { PromptPanel } from "./components/PromptPanel";
import { QueryPanel } from "./components/QueryPanel";
import { RuntimePanel } from "./components/RuntimePanel";
import { DEFAULT_CONNECTION, DEFAULT_PROMPTS, DEFAULT_QUERY } from "./state/defaults";
import type {
AssistantConversationItem,
ConnectionState,
HistoryItem,
NormalizeResultState,
PromptState,
QueryState,
RuntimeRun,
TabKey,
UiMode
} from "./state/types";
const SESSION_CONFIG_KEY = "ndc_normalizer_session_config_v1";
const ASSISTANT_STAGES = ["Разбираю запрос", "Ищу данные", "Собираю ответ"];
function withTs(message: string): string {
return `[${new Date().toLocaleTimeString("ru-RU")}] ${message}`;
}
function diffPrompts(current: PromptState, previous: PromptState | null): string {
if (!previous) return "Previous preset is not selected.";
const fields: Array<keyof PromptState> = ["systemPrompt", "developerPrompt", "domainPrompt", "schemaNotes", "fewShotExamples"];
const changed = fields
.filter((field) => current[field] !== previous[field])
.map((field) => `${field}: ${Math.abs(current[field].length - previous[field].length)} chars delta`);
if (changed.length === 0) return "No changes against previous preset.";
return `Changed fields: ${changed.length}. ${changed.join(" | ")}`;
}
export default function App() {
const [connection, setConnection] = useState<ConnectionState>(DEFAULT_CONNECTION);
const [prompts, setPrompts] = useState<PromptState>(DEFAULT_PROMPTS);
const [query, setQuery] = useState<QueryState>(DEFAULT_QUERY);
const [result, setResult] = useState<NormalizeResultState | null>(null);
const [historyItems, setHistoryItems] = useState<HistoryItem[]>([]);
const [appLogs, setAppLogs] = useState<string[]>([]);
const [activeTab, setActiveTab] = useState<TabKey>("normalized");
const [busy, setBusy] = useState(false);
const [connectionStatus, setConnectionStatus] = useState("");
const [presetList, setPresetList] = useState<
Array<{
id: string;
name: string;
prompt_version: string;
systemPrompt: string;
developerPrompt: string;
domainPrompt: string;
schemaNotes?: string;
fewShotExamples?: string;
}>
>([]);
const [selectedPresetId, setSelectedPresetId] = useState("");
const [presetName, setPresetName] = useState("NDC custom preset");
const [previousPreset, setPreviousPreset] = useState<PromptState | null>(null);
const [diffSummary, setDiffSummary] = useState("");
const [useMock, setUseMock] = useState(false);
const [runs, setRuns] = useState<RuntimeRun[]>([]);
const [selectedRunId, setSelectedRunId] = useState("");
const [runTrace, setRunTrace] = useState<unknown[]>([]);
const [evalBusy, setEvalBusy] = useState(false);
const [evalReport, setEvalReport] = useState<unknown>(null);
const [lastError, setLastError] = useState("");
const [uiMode, setUiMode] = useState<UiMode>("decomposition");
const [assistantSessionId, setAssistantSessionId] = useState("");
const [assistantConversation, setAssistantConversation] = useState<AssistantConversationItem[]>([]);
const [assistantInput, setAssistantInput] = useState("");
const [assistantBusy, setAssistantBusy] = useState(false);
const [assistantStatus, setAssistantStatus] = useState("");
const [assistantError, setAssistantError] = useState("");
const log = (message: string) => {
setAppLogs((prev) => [withTs(message), ...prev].slice(0, 300));
};
function startAssistantStatusTicker(): () => void {
let index = 0;
setAssistantStatus(ASSISTANT_STAGES[0]);
const timer = window.setInterval(() => {
index = Math.min(index + 1, ASSISTANT_STAGES.length - 1);
setAssistantStatus(ASSISTANT_STAGES[index]);
}, 650);
return () => window.clearInterval(timer);
}
useEffect(() => {
const cached = localStorage.getItem(SESSION_CONFIG_KEY);
if (cached) {
try {
const parsed = JSON.parse(cached) as Partial<ConnectionState>;
setConnection((prev) => ({
...prev,
model: parsed.model ?? prev.model,
baseUrl: parsed.baseUrl ?? prev.baseUrl,
temperature: parsed.temperature ?? prev.temperature,
maxOutputTokens: parsed.maxOutputTokens ?? prev.maxOutputTokens
}));
} catch {
// ignore broken local cache
}
}
void refreshHistory();
void refreshPresets();
void refreshRuns();
}, []);
async function refreshHistory() {
try {
const payload = await apiClient.loadHistory();
setHistoryItems(payload.items ?? []);
} catch (error) {
log(`History load error: ${error instanceof Error ? error.message : String(error)}`);
}
}
async function refreshPresets() {
try {
const payload = await apiClient.loadPresets();
setPresetList(payload.presets ?? []);
} catch (error) {
log(`Presets load error: ${error instanceof Error ? error.message : String(error)}`);
}
}
async function refreshRuns() {
try {
const payload = await apiClient.listRuns();
setRuns(payload.items ?? []);
} catch (error) {
log(`Runs load error: ${error instanceof Error ? error.message : String(error)}`);
}
}
function saveLocalConfig() {
localStorage.setItem(
SESSION_CONFIG_KEY,
JSON.stringify({
model: connection.model,
baseUrl: connection.baseUrl,
temperature: connection.temperature,
maxOutputTokens: connection.maxOutputTokens
})
);
log("Local config saved (without API key).");
}
async function testConnection() {
setBusy(true);
setLastError("");
try {
const payload = await apiClient.testConnection(connection);
setConnectionStatus(`OK - ${payload.model}`);
log(`OpenAI connection ok: ${payload.model}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setConnectionStatus("Connection error");
setLastError(`Test connection: ${message}`);
log(`Test connection error: ${message}`);
} finally {
setBusy(false);
}
}
async function normalize(saveAsCase: boolean) {
setBusy(true);
setLastError("");
try {
const payload = await apiClient.normalize({
connection,
prompts,
promptVersion: "normalizer_v2_0_2",
query: {
userQuestion: query.userQuestion,
periodHint: query.periodHint,
businessContext: query.businessContext,
expectedRoute: query.expectedRoute
},
saveAsTestCase: saveAsCase,
useMock
});
setResult(payload);
setActiveTab("normalized");
log(`Normalize done: trace=${payload.trace_id}, validation=${payload.validation.passed ? "passed" : "failed"}`);
void refreshHistory();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setLastError(`Normalize: ${message}`);
log(`Normalize error: ${message}`);
} finally {
setBusy(false);
}
}
function loadSelectedPreset() {
const selected = presetList.find((item) => item.id === selectedPresetId);
if (!selected) {
log("Preset is not selected.");
return;
}
setPreviousPreset(prompts);
setPrompts({
systemPrompt: selected.systemPrompt,
developerPrompt: selected.developerPrompt,
domainPrompt: selected.domainPrompt,
schemaNotes: selected.schemaNotes ?? "",
fewShotExamples: selected.fewShotExamples ?? ""
});
log(`Preset loaded: ${selected.name}`);
}
async function savePreset() {
try {
await apiClient.savePreset({
name: presetName || "NDC preset",
prompt_version: "normalizer_v2_0_2",
systemPrompt: prompts.systemPrompt,
developerPrompt: prompts.developerPrompt,
domainPrompt: prompts.domainPrompt,
schemaNotes: prompts.schemaNotes,
fewShotExamples: prompts.fewShotExamples
});
log("Preset saved.");
await refreshPresets();
} catch (error) {
log(`Preset save error: ${error instanceof Error ? error.message : String(error)}`);
}
}
function resetDefaults() {
setPrompts(DEFAULT_PROMPTS);
log("Prompt panel reset to defaults.");
}
function diffWithPrevious() {
const summary = diffPrompts(prompts, previousPreset);
setDiffSummary(summary);
log(summary);
}
function applyBatchFormat() {
const formatted = query.batchQuestionsRaw
.split(";")
.map((item) => item.trim())
.filter(Boolean)
.join("\n\n");
if (!formatted) {
return;
}
setQuery((prev) => ({
...prev,
batchQuestionsRaw: formatted
}));
log("Batch field formatted: `;` converted to blank-line separators.");
}
async function openTrace(traceId: string) {
try {
const payload = await apiClient.loadTrace(traceId);
const trace = payload.trace as Record<string, unknown>;
const normalized = (trace.parsed_normalized_json ?? null) as NormalizeResultState["normalized"];
setResult({
trace_id: String(trace.trace_id ?? traceId),
ok: Boolean((trace.validation_result as { passed?: boolean } | undefined)?.passed),
normalized,
route_hint_summary:
(trace.route_hint_summary as NormalizeResultState["route_hint_summary"] | undefined) ??
(normalized
? {
route_hint: (normalized as { route_hint?: string }).route_hint ?? null,
confidence: (normalized as { confidence?: { route_hint?: string } }).confidence?.route_hint ?? null
}
: null),
raw_model_output: trace.raw_model_response ?? {},
validation: (trace.validation_result as NormalizeResultState["validation"]) ?? { passed: false, errors: ["validation not found"] },
usage: (trace.usage as NormalizeResultState["usage"]) ?? { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
latency_ms: Number(trace.latency_ms ?? 0),
prompt_version: String(trace.prompt_version ?? "unknown"),
schema_version: String(trace.schema_version ?? "unknown")
});
setActiveTab("raw");
setLastError("");
log(`Trace opened: ${traceId}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setLastError(`Trace: ${message}`);
log(`Trace open error ${traceId}: ${message}`);
}
}
async function startRun() {
try {
const payload = await apiClient.startRun();
setSelectedRunId(payload.run.runId);
log(`Run started: ${payload.run.runId}`);
log("Tip: start run does not execute normalize by itself. Use 'Run eval v2.0.2' button.");
await refreshRuns();
} catch (error) {
log(`Run start error: ${error instanceof Error ? error.message : String(error)}`);
}
}
async function finishRun() {
if (!selectedRunId) return;
try {
await apiClient.finishRun(selectedRunId);
log(`Run finished: ${selectedRunId}`);
await refreshRuns();
} catch (error) {
log(`Run finish error: ${error instanceof Error ? error.message : String(error)}`);
}
}
async function runEval() {
setEvalBusy(true);
setLastError("");
try {
log("Starting eval in v2 contour.");
const rawQuestions = query.batchQuestionsRaw.trim() || query.userQuestion.trim();
if (!rawQuestions) {
throw new Error("Fill batch field or Raw user question first.");
}
const payload = await apiClient.runEval({
connection,
prompts,
promptVersion: "normalizer_v2_0_2",
mode: "single-pass-strict",
rawQuestions,
useMock
});
setEvalReport(payload.report);
log("Eval v2.0.2 run finished.");
const report = payload.report as { metrics?: Record<string, unknown>; run_id?: string };
if (report.run_id) {
log(`Eval run id: ${report.run_id}`);
}
if (report.metrics) {
const metrics = report.metrics;
log(
`Eval metrics v2.0.2: schema=${metrics.schema_validation_pass_rate ?? "n/a"}%, route_accuracy=${metrics.route_resolution_accuracy ?? "n/a"}%, no_route_precision=${metrics.no_route_precision ?? "n/a"}%, state_consistency=${metrics.execution_state_consistency_rate ?? "n/a"}%`
);
}
await refreshHistory();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("Legacy eval runner supports normalized_query_v1 only")) {
setEvalReport({
status: "plan_only",
prompt_version: "normalizer_v2",
reason: "backend eval runner is still legacy-v1 only",
plan_file: "reports/v2_pilot_eval_plan.md",
next_steps: [
"run cheap mock sanity for schema/fragment/scope",
"run small real batch (10-15 messages, temperature=0)",
"run challenge-30 replay with v2 metrics"
]
});
log("Backend is legacy-only for eval right now. Showing v2 pilot plan.");
} else {
setLastError(`Eval: ${message}`);
log(`Eval run error: ${message}`);
}
} finally {
setEvalBusy(false);
}
}
async function copyEvalReport() {
try {
const text = JSON.stringify(evalReport ?? {}, null, 2);
await navigator.clipboard.writeText(text);
log("Eval report copied to clipboard.");
} catch (error) {
log(`Eval report copy error: ${error instanceof Error ? error.message : String(error)}`);
}
}
function resetAssistantSession() {
setAssistantSessionId("");
setAssistantConversation([]);
setAssistantInput("");
setAssistantStatus("");
setAssistantError("");
log("Assistant session reset.");
}
async function sendAssistantMessage() {
const userMessage = assistantInput.trim();
if (!userMessage) {
return;
}
setAssistantBusy(true);
setAssistantError("");
setAssistantInput("");
setAssistantConversation((prev) => [
...prev,
{
message_id: `local-${Date.now()}`,
session_id: assistantSessionId || "pending",
role: "user",
text: userMessage,
reply_type: null,
created_at: new Date().toISOString(),
trace_id: null,
debug: null
}
]);
const stopTicker = startAssistantStatusTicker();
try {
const response = await apiClient.sendAssistantMessage({
connection,
prompts,
userMessage,
sessionId: assistantSessionId || undefined,
promptVersion: "normalizer_v2_0_2",
context: {
periodHint: query.periodHint,
businessContext: query.businessContext
},
useMock
});
setAssistantSessionId(response.session_id);
setAssistantConversation(response.conversation);
setAssistantStatus("Ответ готов");
log(`Assistant reply received: trace=${response.debug.trace_id}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAssistantError(message);
setAssistantStatus("Ошибка ассистента");
log(`Assistant error: ${message}`);
} finally {
stopTicker();
setAssistantBusy(false);
}
}
useEffect(() => {
if (!selectedRunId) {
setRunTrace([]);
return;
}
void apiClient
.runTrace(selectedRunId)
.then((payload) => setRunTrace(payload.items))
.catch((error) => log(`Run trace error: ${error instanceof Error ? error.message : String(error)}`));
}, [selectedRunId]);
return (
<main className="app-root">
<div className="hero">
<h1>NDC AI First Layer</h1>
<p>Два режима в одном интерфейсе: диагностика декомпозиции и диалоговый ассистент на общем backend-контуре.</p>
</div>
<div className="mode-switch-row">
<button type="button" className={uiMode === "assistant" ? "tab active" : "tab"} onClick={() => setUiMode("assistant")}>
Ассистент
</button>
<button type="button" className={uiMode === "decomposition" ? "tab active" : "tab"} onClick={() => setUiMode("decomposition")}>
Декомпозиция
</button>
</div>
{uiMode === "assistant" ? (
<div className="layout-grid">
<ConnectionPanel
value={connection}
onChange={setConnection}
onSaveLocalConfig={saveLocalConfig}
onTestConnection={testConnection}
lastStatus={connectionStatus}
busy={busy || assistantBusy}
/>
<PromptPanel
value={prompts}
onChange={setPrompts}
presets={presetList}
selectedPresetId={selectedPresetId}
onSelectPreset={setSelectedPresetId}
onLoadPreset={loadSelectedPreset}
onSavePreset={savePreset}
onResetDefaults={resetDefaults}
onDiffPrevious={diffWithPrevious}
presetName={presetName}
onPresetNameChange={setPresetName}
diffSummary={diffSummary}
/>
<AssistantPanel
sessionId={assistantSessionId}
conversation={assistantConversation}
inputValue={assistantInput}
onInputChange={setAssistantInput}
periodHint={query.periodHint}
onPeriodHintChange={(value) => setQuery((prev) => ({ ...prev, periodHint: value }))}
businessContext={query.businessContext}
onBusinessContextChange={(value) => setQuery((prev) => ({ ...prev, businessContext: value }))}
useMock={useMock}
onUseMockChange={setUseMock}
onSend={sendAssistantMessage}
onClear={resetAssistantSession}
busy={assistantBusy}
statusText={assistantStatus}
errorMessage={assistantError}
/>
</div>
) : (
<div className="layout-grid">
<ConnectionPanel
value={connection}
onChange={setConnection}
onSaveLocalConfig={saveLocalConfig}
onTestConnection={testConnection}
lastStatus={connectionStatus}
busy={busy}
/>
<PromptPanel
value={prompts}
onChange={setPrompts}
presets={presetList}
selectedPresetId={selectedPresetId}
onSelectPreset={setSelectedPresetId}
onLoadPreset={loadSelectedPreset}
onSavePreset={savePreset}
onResetDefaults={resetDefaults}
onDiffPrevious={diffWithPrevious}
presetName={presetName}
onPresetNameChange={setPresetName}
diffSummary={diffSummary}
/>
<QueryPanel
value={query}
onChange={setQuery}
onApplyBatchFormat={applyBatchFormat}
onNormalize={normalize}
busy={busy}
useMock={useMock}
onUseMockChange={setUseMock}
errorMessage={lastError}
/>
<OutputPanel tab={activeTab} onTabChange={setActiveTab} result={result} appLogs={appLogs} />
<MetricsPanel result={result} />
<HistoryPanel items={historyItems} onRefresh={refreshHistory} onOpenTrace={openTrace} />
<RuntimePanel
runs={runs}
selectedRunId={selectedRunId}
onSelectRun={setSelectedRunId}
onStartRun={startRun}
onFinishRun={finishRun}
onRefreshRuns={refreshRuns}
onRunEval={runEval}
onCopyEvalReport={copyEvalReport}
evalBusy={evalBusy}
traceItems={runTrace}
evalReport={evalReport}
/>
</div>
)}
</main>
);
}
+228
View File
@@ -0,0 +1,228 @@
import type {
AssistantMessageResultState,
AssistantConversationItem,
ConnectionState,
HistoryItem,
NormalizeResultState,
PromptState,
RuntimeRun
} from "../state/types";
const PREFIX = "/api";
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${PREFIX}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {})
}
});
const payload = (await response.json()) as T & { error?: { message?: string } };
if (!response.ok) {
const message = (payload as { error?: { message?: string } }).error?.message ?? "Ошибка запроса";
throw new Error(message);
}
return payload;
}
export const apiClient = {
async testConnection(connection: ConnectionState): Promise<{ ok: boolean; model: string; timestamp: string }> {
return request("/openai/test-connection", {
method: "POST",
body: JSON.stringify({
apiKey: connection.apiKey,
model: connection.model,
baseUrl: connection.baseUrl
})
});
},
async normalize(params: {
connection: ConnectionState;
prompts: PromptState;
promptVersion?: string;
query: {
userQuestion: string;
periodHint?: string;
businessContext?: string;
expectedRoute?: string;
};
saveAsTestCase?: boolean;
useMock?: boolean;
}): Promise<NormalizeResultState> {
return request("/normalize", {
method: "POST",
body: JSON.stringify({
apiKey: params.connection.apiKey,
model: params.connection.model,
baseUrl: params.connection.baseUrl,
temperature: params.connection.temperature,
maxOutputTokens: params.connection.maxOutputTokens,
promptVersion: params.promptVersion,
systemPrompt: params.prompts.systemPrompt,
developerPrompt: params.prompts.developerPrompt,
domainPrompt: params.prompts.domainPrompt,
fewShotExamples: params.prompts.fewShotExamples,
userQuestion: params.query.userQuestion,
context: {
period_hint: params.query.periodHint ?? "",
business_context: params.query.businessContext ?? "",
expected_route: params.query.expectedRoute ?? ""
},
saveAsTestCase: Boolean(params.saveAsTestCase),
useMock: Boolean(params.useMock)
})
});
},
async loadHistory(): Promise<{ ok: boolean; items: HistoryItem[] }> {
return request("/history");
},
async loadTrace(traceId: string): Promise<{ ok: boolean; trace: unknown }> {
return request(`/history/${traceId}`);
},
async loadPresets(): Promise<{
ok: boolean;
presets: Array<{
id: string;
name: string;
prompt_version: string;
systemPrompt: string;
developerPrompt: string;
domainPrompt: string;
schemaNotes?: string;
fewShotExamples?: string;
}>;
}> {
return request("/presets");
},
async savePreset(input: {
id?: string;
name: string;
prompt_version?: string;
systemPrompt: string;
developerPrompt: string;
domainPrompt: string;
schemaNotes?: string;
fewShotExamples?: string;
}): Promise<{ ok: boolean }> {
return request("/presets/save", {
method: "POST",
body: JSON.stringify(input)
});
},
async runEval(input: {
connection: ConnectionState;
prompts: PromptState;
promptVersion?: string;
caseIds?: string[];
useMock?: boolean;
mode?: "standard" | "single-pass-strict";
caseSetFile?: string;
rawQuestions?: string;
}): Promise<{ ok: boolean; report: unknown }> {
return request("/eval/run", {
method: "POST",
body: JSON.stringify({
normalizeConfig: {
apiKey: input.connection.apiKey,
model: input.connection.model,
baseUrl: input.connection.baseUrl,
temperature: input.connection.temperature,
maxOutputTokens: input.connection.maxOutputTokens,
promptVersion: input.promptVersion,
systemPrompt: input.prompts.systemPrompt,
developerPrompt: input.prompts.developerPrompt,
domainPrompt: input.prompts.domainPrompt,
fewShotExamples: input.prompts.fewShotExamples
},
caseIds: input.caseIds,
useMock: Boolean(input.useMock),
mode: input.mode ?? "standard",
caseSetFile: input.caseSetFile,
rawQuestions: input.rawQuestions
})
});
},
async startRun(): Promise<{ ok: boolean; run: RuntimeRun; runId: string; sessionId: string; status: string }> {
return request("/accounting-agent/v1/runs/start", {
method: "POST",
body: JSON.stringify({
initiator: "ndc_operator",
source: "gui"
})
});
},
async finishRun(runId: string): Promise<{ ok: boolean; run: RuntimeRun }> {
return request("/accounting-agent/v1/runs/finish", {
method: "POST",
body: JSON.stringify({
runId,
status: "DONE",
source: "gui",
reason: "Остановлено оператором из GUI"
})
});
},
async listRuns(): Promise<{ ok: boolean; items: RuntimeRun[] }> {
return request("/accounting-agent/v1/runs");
},
async listResults(): Promise<{ ok: boolean; items: unknown[] }> {
return request("/accounting-agent/v1/results");
},
async runTrace(runId: string): Promise<{ ok: boolean; items: unknown[] }> {
return request(`/accounting-agent/v1/trace/run/${runId}`);
},
async sendAssistantMessage(input: {
connection: ConnectionState;
prompts: PromptState;
userMessage: string;
sessionId?: string;
promptVersion?: string;
context?: {
periodHint?: string;
businessContext?: string;
};
useMock?: boolean;
}): Promise<AssistantMessageResultState> {
return request("/assistant/message", {
method: "POST",
body: JSON.stringify({
session_id: input.sessionId ?? "",
mode: "assistant",
message: input.userMessage,
user_message: input.userMessage,
apiKey: input.connection.apiKey,
model: input.connection.model,
baseUrl: input.connection.baseUrl,
temperature: input.connection.temperature,
maxOutputTokens: input.connection.maxOutputTokens,
promptVersion: input.promptVersion ?? "normalizer_v2_0_2",
systemPrompt: input.prompts.systemPrompt,
developerPrompt: input.prompts.developerPrompt,
domainPrompt: input.prompts.domainPrompt,
fewShotExamples: input.prompts.fewShotExamples,
context: {
period_hint: input.context?.periodHint ?? "",
business_context: input.context?.businessContext ?? ""
},
useMock: Boolean(input.useMock)
})
});
},
async loadAssistantSession(sessionId: string): Promise<{ ok: boolean; session: { items: AssistantConversationItem[] } }> {
return request(`/assistant/session/${sessionId}`);
}
};
@@ -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>
);
}
+10
View File
@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
@@ -0,0 +1,28 @@
import type { ConnectionState, PromptState, QueryState } from "./types";
export const DEFAULT_CONNECTION: ConnectionState = {
apiKey: "",
model: "gpt-4o-mini",
baseUrl: "https://api.openai.com/v1",
temperature: 0,
maxOutputTokens: 700
};
export const DEFAULT_PROMPTS: PromptState = {
systemPrompt: "Ты semantic-normalizer для бухгалтерского ассистента NDC. Возвращай только JSON по схеме normalized_query_v2_0_2.",
developerPrompt:
"Сначала делай decomposition сообщения на task fragments, затем определяй domain scope и route-critical flags. Для каждого fragment заполняй execution_readiness + route_status + no_route_reason. Если fragment routable, не оставляй его в no_route.",
domainPrompt:
"Контур: данные текущего предприятия в 1С/NDC. In-scope: документы, проводки, взаиморасчеты, остатки, периодное закрытие, аномалии и контрольные проверки. Out-of-scope: общая теория, законы и оффтоп.",
schemaNotes: "schema_version: normalized_query_v2_0_2. Строгий JSON без дополнительных полей.",
fewShotExamples:
"Q: Проверь по поставщикам хвосты и разложи цепочку документов/оплат. => fragment in_scope, flags: multi_entity + chain_explanation. Q: Как вообще по ФСБУ? => out_of_scope/generic_accounting."
};
export const DEFAULT_QUERY: QueryState = {
userQuestion: "",
batchQuestionsRaw: "",
periodHint: "",
businessContext: "",
expectedRoute: ""
};
+172
View File
@@ -0,0 +1,172 @@
export type TabKey = "normalized" | "fragments" | "scope" | "flags" | "route" | "raw" | "validation" | "logs";
export interface ConnectionState {
apiKey: string;
model: string;
baseUrl: string;
temperature: number;
maxOutputTokens: number;
}
export interface PromptState {
systemPrompt: string;
developerPrompt: string;
domainPrompt: string;
schemaNotes: string;
fewShotExamples: string;
}
export interface QueryState {
userQuestion: string;
batchQuestionsRaw: string;
periodHint: string;
businessContext: string;
expectedRoute: string;
}
export interface NormalizeResultState {
trace_id: string;
ok: boolean;
normalized: unknown;
route_hint_summary: unknown;
raw_model_output: unknown;
validation: {
passed: boolean;
errors: string[];
};
usage: {
input_tokens: number;
output_tokens: number;
total_tokens: number;
};
latency_ms: number;
prompt_version: string;
schema_version: string;
}
export interface HistoryItem {
trace_id: string;
timestamp: string;
model: string;
question_short: string;
confidence: string | null;
validation_passed: boolean;
route_hint: string | null;
save_status: "saved";
}
export interface RuntimeRun {
sessionId: string;
runId: string;
status: string;
initiator: string;
source: string;
createdAt: string;
updatedAt: string;
}
export type UiMode = "assistant" | "decomposition";
export type AssistantFallbackType = "none" | "out_of_scope" | "clarification" | "partial" | "unknown";
export type AssistantReplyType =
| "factual"
| "factual_with_explanation"
| "partial_coverage"
| "clarification_required"
| "out_of_scope"
| "empty_but_valid"
| "no_grounded_answer"
| "route_mismatch_blocked"
| "backend_error";
export type RetrievalResultStatus = "ok" | "empty" | "partial" | "error";
export type RetrievalResultType = "list" | "summary" | "object" | "chain" | "ranking";
export type RetrievalConfidence = "high" | "medium" | "low";
export interface AssistantRequirement {
requirement_id: string;
source_fragment_id: string | null;
requirement_text: string;
subject_tokens: string[];
status: "covered" | "partially_covered" | "uncovered" | "clarification_needed" | "out_of_scope";
route: string | null;
}
export interface RequirementCoverageReport {
requirements_total: number;
requirements_covered: number;
requirements_uncovered: string[];
requirements_partially_covered: string[];
clarification_needed_for: string[];
out_of_scope_requirements: string[];
}
export interface AnswerGroundingCheck {
status: "grounded" | "partial" | "no_grounded_answer" | "route_mismatch_blocked";
route_subject_match: boolean;
missing_requirements: string[];
reasons: string[];
why_included_summary: string[];
selection_reason_summary: string[];
}
export interface UnifiedRetrievalResult {
fragment_id: string;
requirement_ids: string[];
route: string;
status: RetrievalResultStatus;
result_type: RetrievalResultType;
items: Array<Record<string, unknown>>;
summary: Record<string, unknown>;
evidence: Array<Record<string, unknown>>;
why_included: string[];
selection_reason: string[];
risk_factors: string[];
business_interpretation: string[];
confidence: RetrievalConfidence;
limitations: string[];
errors: string[];
}
export interface AssistantDebugState {
trace_id: string;
prompt_version: string;
schema_version: string;
fallback_type: AssistantFallbackType;
route_summary: unknown;
fragments: unknown[];
requirements_extracted: AssistantRequirement[];
coverage_report: RequirementCoverageReport;
routes: Array<Record<string, unknown>>;
retrieval_status: Array<{
fragment_id: string;
requirement_ids: string[];
route: string;
status: RetrievalResultStatus;
result_type: RetrievalResultType;
}>;
retrieval_results: UnifiedRetrievalResult[];
answer_grounding_check: AnswerGroundingCheck;
dropped_intent_segments: string[];
normalized: unknown;
}
export interface AssistantConversationItem {
message_id: string;
session_id: string;
role: "user" | "assistant";
text: string;
reply_type: AssistantReplyType | null;
created_at: string;
trace_id: string | null;
debug: AssistantDebugState | null;
}
export interface AssistantMessageResultState {
ok: boolean;
session_id: string;
assistant_reply: string;
reply_type: AssistantReplyType;
conversation_item: AssistantConversationItem;
debug: AssistantDebugState;
conversation: AssistantConversationItem[];
}
+457
View File
@@ -0,0 +1,457 @@
@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&family=Space+Grotesk:wght@500;700&display=swap");
:root {
--bg-main: #060a08;
--bg-soft: #0e1511;
--bg-panel: rgba(15, 24, 19, 0.85);
--bg-panel-accent: rgba(38, 62, 45, 0.45);
--line: rgba(143, 255, 173, 0.3);
--line-strong: rgba(143, 255, 173, 0.72);
--text-main: #f3ffee;
--text-muted: #9bb8a5;
--lime-main: #8fffad;
--lime-press: #59db83;
--danger: #ff7e7e;
--radius-lg: 20px;
--radius-md: 14px;
--shadow: 0 16px 44px rgba(0, 0, 0, 0.35);
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
min-height: 100%;
font-family: "Manrope", "Segoe UI", sans-serif;
background: radial-gradient(circle at 15% -10%, #1f3b2f 0%, transparent 40%),
radial-gradient(circle at 90% 10%, #1d2f24 0%, transparent 35%),
linear-gradient(165deg, var(--bg-main) 0%, #070d0a 100%);
color: var(--text-main);
}
.app-root {
max-width: 1720px;
margin: 0 auto;
padding: 28px 24px 42px;
}
.hero {
margin-bottom: 20px;
padding: 20px 22px;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: linear-gradient(145deg, rgba(17, 29, 22, 0.94), rgba(10, 16, 13, 0.92));
box-shadow: var(--shadow);
animation: rise 0.5s ease-out;
}
.hero h1 {
font-family: "Space Grotesk", "Manrope", sans-serif;
margin: 0 0 8px;
font-size: clamp(1.5rem, 3vw, 2.3rem);
letter-spacing: 0.04em;
}
.hero p {
margin: 0;
color: var(--text-muted);
}
.layout-grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 16px;
}
.mode-switch-row {
display: flex;
gap: 8px;
margin: 0 0 14px;
}
.panel-frame {
grid-column: span 12;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: linear-gradient(165deg, var(--bg-panel), rgba(10, 17, 13, 0.88));
overflow: hidden;
box-shadow: var(--shadow);
animation: rise 0.4s ease-out;
}
.panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
padding: 14px 18px 10px;
border-bottom: 1px solid var(--line);
background: linear-gradient(90deg, var(--bg-panel-accent), transparent 70%);
}
.panel-header h2 {
margin: 0;
font-size: 1.02rem;
letter-spacing: 0.02em;
}
.panel-header p {
margin: 6px 0 0;
font-size: 0.85rem;
color: var(--text-muted);
}
.panel-body {
padding: 14px 18px 18px;
}
.status-chip {
border: 1px solid var(--line);
border-radius: 999px;
padding: 4px 10px;
color: var(--lime-main);
font-size: 0.78rem;
}
input,
select,
textarea,
button {
font-family: "Manrope", sans-serif;
}
label {
display: flex;
flex-direction: column;
gap: 6px;
color: var(--text-muted);
font-size: 0.84rem;
}
input,
select,
textarea {
border: 1px solid rgba(170, 255, 194, 0.26);
border-radius: var(--radius-md);
background: rgba(7, 13, 10, 0.92);
color: var(--text-main);
padding: 10px 12px;
outline: none;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
input:focus,
select:focus,
textarea:focus {
border-color: var(--line-strong);
box-shadow: 0 0 0 3px rgba(143, 255, 173, 0.2);
}
textarea {
resize: vertical;
min-height: 86px;
}
button {
border: 1px solid rgba(176, 255, 199, 0.35);
border-radius: 999px;
background: linear-gradient(180deg, #8fffad 0%, #62e286 100%);
color: #08100a;
font-weight: 700;
font-size: 0.83rem;
letter-spacing: 0.02em;
cursor: pointer;
padding: 9px 14px;
transition: transform 0.16s ease, filter 0.2s ease, box-shadow 0.18s ease;
}
button:hover {
filter: brightness(1.04);
transform: translateY(-1px);
box-shadow: 0 8px 20px rgba(127, 255, 170, 0.32);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.button-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 12px;
}
.checkbox-row {
flex-direction: row;
align-items: center;
gap: 8px;
color: var(--text-main);
}
.grid-two {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.full-width {
grid-column: 1 / -1;
}
.tab-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.tab {
background: transparent;
color: var(--text-main);
border: 1px solid var(--line);
}
.tab.active {
border-color: var(--line-strong);
background: linear-gradient(180deg, rgba(143, 255, 173, 0.25), rgba(143, 255, 173, 0.1));
}
.assistant-chat-list {
max-height: 420px;
overflow: auto;
display: grid;
gap: 10px;
padding: 4px;
border: 1px solid rgba(156, 255, 189, 0.2);
border-radius: var(--radius-md);
background: rgba(6, 10, 8, 0.65);
}
.assistant-empty {
padding: 18px;
text-align: center;
}
.assistant-msg {
border: 1px solid rgba(157, 255, 190, 0.2);
border-radius: 12px;
background: rgba(11, 18, 14, 0.74);
padding: 10px;
}
.assistant-msg.user {
border-color: rgba(110, 198, 255, 0.38);
background: rgba(13, 20, 24, 0.76);
}
.assistant-msg.assistant {
border-color: rgba(157, 255, 190, 0.25);
}
.assistant-msg-head {
display: flex;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
font-size: 0.78rem;
color: var(--text-muted);
}
.assistant-msg-body {
white-space: pre-wrap;
line-height: 1.45;
font-size: 0.9rem;
}
.assistant-trace {
margin-top: 6px;
color: var(--text-muted);
font-size: 0.75rem;
}
.assistant-debug {
margin-top: 8px;
}
.assistant-debug summary {
cursor: pointer;
color: var(--lime-main);
font-size: 0.8rem;
}
.assistant-compose {
margin-top: 12px;
display: grid;
gap: 10px;
}
.json-view {
margin: 0;
width: 100%;
min-height: 180px;
max-height: 420px;
overflow: auto;
background: #050806;
border: 1px solid rgba(156, 255, 189, 0.2);
border-radius: var(--radius-md);
padding: 12px;
color: #d6ffdf;
font-family: "JetBrains Mono", "Consolas", monospace;
font-size: 0.78rem;
line-height: 1.45;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
}
.metrics-grid div {
background: rgba(11, 18, 14, 0.7);
border: 1px solid rgba(157, 255, 190, 0.24);
border-radius: 12px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 4px;
}
.metrics-grid span {
color: var(--text-muted);
font-size: 0.75rem;
}
.metrics-grid strong {
font-size: 0.84rem;
color: var(--lime-main);
}
.history-list {
display: grid;
gap: 8px;
max-height: 340px;
overflow: auto;
}
.history-item {
width: 100%;
text-align: left;
border-radius: 12px;
border: 1px solid rgba(157, 255, 190, 0.24);
background: rgba(11, 18, 14, 0.78);
color: var(--text-main);
padding: 10px;
}
.history-item p {
margin: 8px 0;
color: var(--text-muted);
font-size: 0.82rem;
}
.history-item.selected {
border-color: var(--line-strong);
}
.history-row {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 0.76rem;
color: var(--text-muted);
}
.runtime-grid {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 12px;
}
.runtime-runs {
max-height: 360px;
overflow: auto;
display: grid;
gap: 8px;
}
.eval-report-wrap {
position: relative;
}
.copy-cube-button {
position: absolute;
right: 10px;
bottom: 10px;
width: 34px;
height: 34px;
border-radius: 10px;
padding: 0;
min-width: 34px;
display: grid;
place-items: center;
font-size: 0.92rem;
line-height: 1;
}
.muted {
color: var(--text-muted);
}
.diff-summary {
margin-top: 10px;
font-size: 0.82rem;
color: var(--lime-main);
}
.error-text {
margin-top: 10px;
color: var(--danger);
font-size: 0.84rem;
}
@media (max-width: 1200px) {
.metrics-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 920px) {
.grid-two,
.runtime-grid {
grid-template-columns: 1fr;
}
.metrics-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.app-root {
padding: 18px 12px 24px;
}
.metrics-grid {
grid-template-columns: 1fr;
}
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"resolveJsonModule": true,
"isolatedModules": true,
"types": ["vite/client"]
},
"include": ["src"]
}
@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/components/assistantpanel.tsx","./src/components/connectionpanel.tsx","./src/components/historypanel.tsx","./src/components/jsonview.tsx","./src/components/metricspanel.tsx","./src/components/outputpanel.tsx","./src/components/panelframe.tsx","./src/components/promptpanel.tsx","./src/components/querypanel.tsx","./src/components/runtimepanel.tsx","./src/state/defaults.ts","./src/state/types.ts"],"version":"5.9.3"}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5174,
proxy: {
"/api": {
target: "http://localhost:8787",
changeOrigin: true
}
}
}
});