ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Рефакторинг этапов Stage 3.8 - юи

This commit is contained in:
dctouch 2026-04-12 00:54:55 +03:00
parent d65969d2ff
commit 8167bd228d
18 changed files with 1408 additions and 70 deletions

View File

@ -2592,9 +2592,26 @@ Acceptance (Stage 3):
1. LLM outputs strictly validated schema for extraction/decomposition (no free-form). 1. LLM outputs strictly validated schema for extraction/decomposition (no free-form).
2. Deterministic guards can block or downgrade answers when evidence insufficient. 2. Deterministic guards can block or downgrade answers when evidence insufficient.
3. False route drifts and generic responses reduced in regression packs. 3. False route drifts and generic responses reduced in regression packs.
4. Manual markup shows increase in correct/grounded labels. 4. Manual markup shows increase in correct/grounded labels.
Status: In validation (functional gates green; manual re-markup trend confirmation pending) ### Stage 3 Exit Audit (2026-04-11)
1. Functional gate:
- `npm --prefix llm_normalizer/backend run build` passed.
2. Regression gate:
- Stage 3 focused suite passed: `9 files / 356 tests`.
- (`addressQueryRuntimeM23`, `assistantWave17RunRegression20260411`, `assistantWave18ManualCommentsRegression`,
`assistantLivingRouter`, `assistantLivingChatMode`, `assistantSoftPolicyReply`,
`assistantBoundaryFallbackReply`, `assistantAnswerPolicyV11`, `assistantSemanticExtractionContract`).
3. Runtime stability gate:
- address lane includes protective fallback for legacy MCP schema error (`SubcontoDt1`) and avoids hard execution drop for debt-lifecycle receivables prompts.
4. Manual smoke:
- reviewer confirmed fixed questions on latest spot checks; domain breadth expansion is tracked separately and is not a Stage 3 blocker.
5. Full backend suite note (non-blocking for Stage 4 kickoff):
- `npm --prefix llm_normalizer/backend run test` reports red integration groups with `5000ms` timeout budget (`assistantEvalHarness`, `assistantP0EvalHarness`, `assistantStage2EvalHarness`, `assistantStage3LifecycleAcceptanceProbe`, part of `assistantEndpoint`/`assistantBroadGuard`).
- one user-facing policy assertion currently diverges when live MCP returns aborted fetch (`This operation was aborted`), producing execution-error soft reply instead of expected clarification template.
- these failures are tracked as stability/infra debt and do not block Stage 4 answer-layer kickoff for already green Stage 3 focused contract pack.
Status: Completed (Stage 3 gate approved; domain expansion moved to Stage 5 backlog)
## Stage 4 (P2): Human-Centric Answer Layer ## Stage 4 (P2): Human-Centric Answer Layer
@ -2607,7 +2624,12 @@ Goal:
- best next step. - best next step.
2. Keep claim-to-evidence binding strict. 2. Keep claim-to-evidence binding strict.
Status: Planned Stage 4 kickoff preconditions:
1. Stage 3 functional + regression gates are green.
2. No route/MCP interface changes are required to start Stage 4.
3. Remaining acknowledged risk: domain coverage is still limited and will be expanded iteratively.
Status: Ready to start (kickoff approved on 2026-04-11)
## Stage 5 (P3): Quality Loop Driven By GUI Markup ## Stage 5 (P3): Quality Loop Driven By GUI Markup

View File

@ -3,8 +3,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod }; return (mod && mod.__esModule) ? mod : { "default": mod };
}; };
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.ARCH_EXPORT_2020_DIR = exports.SCHEMAS_DIR = exports.EVAL_DATASETS_DIR = exports.REPORTS_DIR = exports.PROMPTS_DIR = exports.AUTORUN_GENERATOR_HISTORY_FILE = exports.AUTORUN_GENERATOR_DIR = exports.AUTORUN_ANNOTATIONS_FILE = exports.AUTORUN_ANNOTATIONS_DIR = exports.ASSISTANT_SESSIONS_DIR = exports.EVAL_CASES_DIR = exports.PRESETS_DIR = exports.TRACES_DIR = exports.DATA_DIR = exports.VAT_PAYABLE_19_PREFIXES = exports.VAT_PAYABLE_68_PREFIXES = exports.ASSISTANT_MCP_LIVE_LIMIT = exports.ASSISTANT_MCP_TIMEOUT_MS = exports.ASSISTANT_MCP_CHANNEL = exports.ASSISTANT_MCP_PROXY_URL = exports.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = exports.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_RUNTIME_V1 = exports.FEATURE_ASSISTANT_STAGE2_EVAL_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1 = exports.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNITS_V1 = exports.FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1 = exports.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = exports.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 = exports.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = exports.FEATURE_ASSISTANT_BROAD_GUARD_V1 = exports.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1 = exports.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = exports.FEATURE_ASSISTANT_CONTRACTS_V11 = exports.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = exports.DEFAULT_PROMPT_VERSION = exports.DEFAULT_MAX_OUTPUT_TOKENS = exports.DEFAULT_TEMPERATURE = exports.DEFAULT_MODEL = exports.DEFAULT_OPENAI_BASE_URL = exports.TIMEZONE = exports.PORT = exports.MODULE_ROOT = exports.BACKEND_ROOT = void 0; exports.EVAL_DATASETS_DIR = exports.REPORTS_DIR = exports.PROMPTS_DIR = exports.AUTORUN_GENERATOR_HISTORY_FILE = exports.AUTORUN_GENERATOR_DIR = exports.AUTORUN_ANNOTATIONS_FILE = exports.AUTORUN_ANNOTATIONS_DIR = exports.ASSISTANT_ANNOTATIONS_FILE = exports.ASSISTANT_ANNOTATIONS_DIR = exports.ASSISTANT_SESSIONS_DIR = exports.EVAL_CASES_DIR = exports.PRESETS_DIR = exports.TRACES_DIR = exports.DATA_DIR = exports.VAT_PAYABLE_19_PREFIXES = exports.VAT_PAYABLE_68_PREFIXES = exports.ASSISTANT_MCP_LIVE_LIMIT = exports.ASSISTANT_MCP_TIMEOUT_MS = exports.ASSISTANT_MCP_CHANNEL = exports.ASSISTANT_MCP_PROXY_URL = exports.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = exports.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_RUNTIME_V1 = exports.FEATURE_ASSISTANT_STAGE2_EVAL_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1 = exports.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNITS_V1 = exports.FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1 = exports.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = exports.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 = exports.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = exports.FEATURE_ASSISTANT_BROAD_GUARD_V1 = exports.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1 = exports.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = exports.FEATURE_ASSISTANT_CONTRACTS_V11 = exports.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = exports.DEFAULT_PROMPT_VERSION = exports.DEFAULT_MAX_OUTPUT_TOKENS = exports.DEFAULT_TEMPERATURE = exports.DEFAULT_MODEL = exports.DEFAULT_OPENAI_BASE_URL = exports.TIMEZONE = exports.PORT = exports.MODULE_ROOT = exports.BACKEND_ROOT = void 0;
exports.MANUAL_CASE_DECISION_SCHEMA_FILE = exports.ASSISTANT_CAPABILITIES_REGISTRY_FILE = exports.ASSISTANT_CANON_FILE = void 0; exports.MANUAL_CASE_DECISION_SCHEMA_FILE = exports.ASSISTANT_CAPABILITIES_REGISTRY_FILE = exports.ASSISTANT_CANON_FILE = exports.ARCH_EXPORT_2020_DIR = exports.SCHEMAS_DIR = void 0;
const path_1 = __importDefault(require("path")); const path_1 = __importDefault(require("path"));
exports.BACKEND_ROOT = path_1.default.resolve(__dirname, ".."); exports.BACKEND_ROOT = path_1.default.resolve(__dirname, "..");
exports.MODULE_ROOT = path_1.default.resolve(exports.BACKEND_ROOT, ".."); exports.MODULE_ROOT = path_1.default.resolve(exports.BACKEND_ROOT, "..");
@ -72,6 +72,8 @@ exports.TRACES_DIR = path_1.default.resolve(exports.DATA_DIR, "traces");
exports.PRESETS_DIR = path_1.default.resolve(exports.DATA_DIR, "presets"); exports.PRESETS_DIR = path_1.default.resolve(exports.DATA_DIR, "presets");
exports.EVAL_CASES_DIR = path_1.default.resolve(exports.DATA_DIR, "eval_cases"); exports.EVAL_CASES_DIR = path_1.default.resolve(exports.DATA_DIR, "eval_cases");
exports.ASSISTANT_SESSIONS_DIR = path_1.default.resolve(exports.DATA_DIR, "assistant_sessions"); exports.ASSISTANT_SESSIONS_DIR = path_1.default.resolve(exports.DATA_DIR, "assistant_sessions");
exports.ASSISTANT_ANNOTATIONS_DIR = path_1.default.resolve(exports.DATA_DIR, "assistant_annotations");
exports.ASSISTANT_ANNOTATIONS_FILE = path_1.default.resolve(exports.ASSISTANT_ANNOTATIONS_DIR, "annotations.json");
exports.AUTORUN_ANNOTATIONS_DIR = path_1.default.resolve(exports.DATA_DIR, "autorun_annotations"); exports.AUTORUN_ANNOTATIONS_DIR = path_1.default.resolve(exports.DATA_DIR, "autorun_annotations");
exports.AUTORUN_ANNOTATIONS_FILE = path_1.default.resolve(exports.AUTORUN_ANNOTATIONS_DIR, "annotations.json"); exports.AUTORUN_ANNOTATIONS_FILE = path_1.default.resolve(exports.AUTORUN_ANNOTATIONS_DIR, "annotations.json");
exports.AUTORUN_GENERATOR_DIR = path_1.default.resolve(exports.DATA_DIR, "autorun_generators"); exports.AUTORUN_GENERATOR_DIR = path_1.default.resolve(exports.DATA_DIR, "autorun_generators");

View File

@ -1,8 +1,118 @@
"use strict"; "use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.buildAssistantRouter = buildAssistantRouter; exports.buildAssistantRouter = buildAssistantRouter;
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const express_1 = require("express"); const express_1 = require("express");
const config_1 = require("../config");
const http_1 = require("../utils/http"); const http_1 = require("../utils/http");
function toStringSafe(value) {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function toNumberSafe(value) {
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 clampInt(value, min, max, fallback) {
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 parseComment(value) {
if (typeof value !== "string")
return "";
return value.trim().slice(0, 4000);
}
function parseAnnotationAuthor(value) {
const normalized = toStringSafe(value);
if (!normalized)
return null;
return normalized.slice(0, 80);
}
function toRecord(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value;
}
function generateAnnotationId() {
return `asann-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function ensureAnnotationStoreDir() {
const dir = path_1.default.dirname(config_1.ASSISTANT_ANNOTATIONS_FILE);
if (!fs_1.default.existsSync(dir)) {
fs_1.default.mkdirSync(dir, { recursive: true });
}
}
function readAssistantAnnotations() {
if (!fs_1.default.existsSync(config_1.ASSISTANT_ANNOTATIONS_FILE)) {
return [];
}
try {
const parsed = JSON.parse(fs_1.default.readFileSync(config_1.ASSISTANT_ANNOTATIONS_FILE, "utf-8"));
if (!Array.isArray(parsed)) {
return [];
}
return parsed
.map((item) => toRecord(item))
.filter((item) => item !== null)
.map((item) => {
const context = toRecord(item.context);
const technicalContext = toRecord(item.technical_context);
const createdAt = toStringSafe(item.created_at) ?? new Date().toISOString();
const updatedAt = toStringSafe(item.updated_at) ?? createdAt;
return {
annotation_id: toStringSafe(item.annotation_id) ?? "",
session_id: toStringSafe(item.session_id) ?? "",
message_id: toStringSafe(item.message_id) ?? "",
message_index: clampInt(toNumberSafe(item.message_index), 0, 100_000, 0),
rating: clampInt(toNumberSafe(item.rating), 1, 5, 3),
comment: parseComment(item.comment),
annotation_author: parseAnnotationAuthor(item.annotation_author),
created_at: createdAt,
updated_at: updatedAt,
context: {
trace_id: toStringSafe(context?.trace_id),
reply_type: toStringSafe(context?.reply_type),
question_text: toStringSafe(context?.question_text),
answer_text: toStringSafe(context?.answer_text)
},
technical_context: technicalContext
};
})
.filter((item) => item.annotation_id && item.session_id && item.message_id && item.comment.length > 0)
.sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at));
}
catch {
return [];
}
}
function writeAssistantAnnotations(items) {
ensureAnnotationStoreDir();
fs_1.default.writeFileSync(config_1.ASSISTANT_ANNOTATIONS_FILE, JSON.stringify(items, null, 2), "utf-8");
}
function annotationKey(sessionId, messageIndex) {
return `${sessionId}::${messageIndex}`;
}
function buildAssistantRouter(services) { function buildAssistantRouter(services) {
const router = (0, express_1.Router)(); const router = (0, express_1.Router)();
router.post("/api/assistant/message", async (req, res, next) => { router.post("/api/assistant/message", async (req, res, next) => {
@ -45,5 +155,103 @@ function buildAssistantRouter(services) {
next(error); next(error);
} }
}); });
router.get("/api/assistant/annotations", (req, res, next) => {
try {
const query = req.query;
const sessionIdFilter = toStringSafe(query.session_id);
const limit = clampInt(toNumberSafe(query.limit), 1, 1000, 300);
const items = readAssistantAnnotations()
.filter((item) => (sessionIdFilter ? item.session_id === sessionIdFilter : true))
.slice(0, limit);
(0, http_1.ok)(res, {
ok: true,
generated_at: new Date().toISOString(),
filters_applied: {
session_id: sessionIdFilter ?? null,
limit
},
items
});
}
catch (error) {
next(error);
}
});
router.post("/api/assistant/annotations", (req, res, next) => {
try {
const body = toRecord(req.body);
if (!body) {
throw new http_1.ApiError("INVALID_ASSISTANT_ANNOTATION_PAYLOAD", "JSON body is required", 400);
}
const sessionId = toStringSafe(body.session_id);
const messageIndexRaw = toNumberSafe(body.message_index);
const ratingRaw = toNumberSafe(body.rating);
const comment = parseComment(body.comment);
const annotationAuthor = parseAnnotationAuthor(body.annotation_author);
if (!sessionId) {
throw new http_1.ApiError("INVALID_ASSISTANT_ANNOTATION_PAYLOAD", "session_id is required", 400);
}
if (messageIndexRaw === null) {
throw new http_1.ApiError("INVALID_ASSISTANT_ANNOTATION_PAYLOAD", "message_index is required", 400);
}
const messageIndex = clampInt(messageIndexRaw, 0, 100_000, 0);
if (ratingRaw === null) {
throw new http_1.ApiError("INVALID_ASSISTANT_ANNOTATION_PAYLOAD", "rating is required", 400);
}
const rating = clampInt(ratingRaw, 1, 5, 3);
if (!comment) {
throw new http_1.ApiError("INVALID_ASSISTANT_ANNOTATION_PAYLOAD", "comment is required", 400);
}
const session = services.assistantService.getSession(sessionId);
if (!session) {
throw new http_1.ApiError("ASSISTANT_SESSION_NOT_FOUND", `Session not found: ${sessionId}`, 404);
}
if (messageIndex >= session.items.length) {
throw new http_1.ApiError("ASSISTANT_MESSAGE_NOT_FOUND", `Message index ${messageIndex} out of range`, 400);
}
const targetMessage = session.items[messageIndex];
if (!targetMessage || targetMessage.role !== "assistant") {
throw new http_1.ApiError("ASSISTANT_MESSAGE_NOT_ASSISTANT", "Only assistant answers can be annotated", 400);
}
const pairedUserQuestion = [...session.items.slice(0, messageIndex)].reverse().find((item) => item.role === "user") ?? null;
const nowIso = new Date().toISOString();
const annotations = readAssistantAnnotations();
const key = annotationKey(sessionId, messageIndex);
const existingIndex = annotations.findIndex((item) => annotationKey(item.session_id, item.message_index) === key);
const existing = existingIndex >= 0 ? annotations[existingIndex] : null;
const annotation = {
annotation_id: existing?.annotation_id ?? generateAnnotationId(),
session_id: sessionId,
message_id: targetMessage.message_id,
message_index: messageIndex,
rating,
comment,
annotation_author: annotationAuthor,
created_at: existing?.created_at ?? nowIso,
updated_at: nowIso,
context: {
trace_id: toStringSafe(targetMessage.trace_id),
reply_type: toStringSafe(targetMessage.reply_type),
question_text: toStringSafe(pairedUserQuestion?.text),
answer_text: toStringSafe(targetMessage.text)
},
technical_context: toRecord(targetMessage.debug)
};
if (existingIndex >= 0) {
annotations[existingIndex] = annotation;
}
else {
annotations.push(annotation);
}
writeAssistantAnnotations(annotations);
(0, http_1.ok)(res, {
ok: true,
annotation
});
}
catch (error) {
next(error);
}
});
return router; return router;
} }

View File

@ -31,6 +31,7 @@ function createApp() {
(0, files_1.ensureDir)(config_1.EVAL_CASES_DIR); (0, files_1.ensureDir)(config_1.EVAL_CASES_DIR);
(0, files_1.ensureDir)(config_1.REPORTS_DIR); (0, files_1.ensureDir)(config_1.REPORTS_DIR);
(0, files_1.ensureDir)(config_1.ASSISTANT_SESSIONS_DIR); (0, files_1.ensureDir)(config_1.ASSISTANT_SESSIONS_DIR);
(0, files_1.ensureDir)(config_1.ASSISTANT_ANNOTATIONS_DIR);
(0, files_1.ensureDir)(config_1.AUTORUN_ANNOTATIONS_DIR); (0, files_1.ensureDir)(config_1.AUTORUN_ANNOTATIONS_DIR);
(0, files_1.ensureDir)(config_1.AUTORUN_GENERATOR_DIR); (0, files_1.ensureDir)(config_1.AUTORUN_GENERATOR_DIR);
const app = (0, express_1.default)(); const app = (0, express_1.default)();

View File

@ -134,6 +134,8 @@ export const TRACES_DIR = path.resolve(DATA_DIR, "traces");
export const PRESETS_DIR = path.resolve(DATA_DIR, "presets"); export const PRESETS_DIR = path.resolve(DATA_DIR, "presets");
export const EVAL_CASES_DIR = path.resolve(DATA_DIR, "eval_cases"); export const EVAL_CASES_DIR = path.resolve(DATA_DIR, "eval_cases");
export const ASSISTANT_SESSIONS_DIR = path.resolve(DATA_DIR, "assistant_sessions"); export const ASSISTANT_SESSIONS_DIR = path.resolve(DATA_DIR, "assistant_sessions");
export const ASSISTANT_ANNOTATIONS_DIR = path.resolve(DATA_DIR, "assistant_annotations");
export const ASSISTANT_ANNOTATIONS_FILE = path.resolve(ASSISTANT_ANNOTATIONS_DIR, "annotations.json");
export const AUTORUN_ANNOTATIONS_DIR = path.resolve(DATA_DIR, "autorun_annotations"); export const AUTORUN_ANNOTATIONS_DIR = path.resolve(DATA_DIR, "autorun_annotations");
export const AUTORUN_ANNOTATIONS_FILE = path.resolve(AUTORUN_ANNOTATIONS_DIR, "annotations.json"); export const AUTORUN_ANNOTATIONS_FILE = path.resolve(AUTORUN_ANNOTATIONS_DIR, "annotations.json");
export const AUTORUN_GENERATOR_DIR = path.resolve(DATA_DIR, "autorun_generators"); export const AUTORUN_GENERATOR_DIR = path.resolve(DATA_DIR, "autorun_generators");

View File

@ -1,8 +1,140 @@
import fs from "fs";
import path from "path";
import { Router } from "express"; import { Router } from "express";
import { ASSISTANT_ANNOTATIONS_FILE } from "../config";
import type { AppServices } from "../serverContext"; import type { AppServices } from "../serverContext";
import type { AssistantMessageRequestPayload } from "../types/assistant"; import type { AssistantMessageRequestPayload } from "../types/assistant";
import { ApiError, ok } from "../utils/http"; import { ApiError, ok } from "../utils/http";
interface AssistantAnnotationRecord {
annotation_id: string;
session_id: string;
message_id: string;
message_index: number;
rating: number;
comment: string;
annotation_author: string | null;
created_at: string;
updated_at: string;
context: {
trace_id: string | null;
reply_type: string | null;
question_text: string | null;
answer_text: string | null;
};
technical_context: Record<string, unknown> | null;
}
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 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 parseComment(value: unknown): string {
if (typeof value !== "string") return "";
return value.trim().slice(0, 4000);
}
function parseAnnotationAuthor(value: unknown): string | null {
const normalized = toStringSafe(value);
if (!normalized) return null;
return normalized.slice(0, 80);
}
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 generateAnnotationId(): string {
return `asann-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function ensureAnnotationStoreDir(): void {
const dir = path.dirname(ASSISTANT_ANNOTATIONS_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function readAssistantAnnotations(): AssistantAnnotationRecord[] {
if (!fs.existsSync(ASSISTANT_ANNOTATIONS_FILE)) {
return [];
}
try {
const parsed = JSON.parse(fs.readFileSync(ASSISTANT_ANNOTATIONS_FILE, "utf-8")) as unknown;
if (!Array.isArray(parsed)) {
return [];
}
return parsed
.map((item) => toRecord(item))
.filter((item): item is Record<string, unknown> => item !== null)
.map((item) => {
const context = toRecord(item.context);
const technicalContext = toRecord(item.technical_context);
const createdAt = toStringSafe(item.created_at) ?? new Date().toISOString();
const updatedAt = toStringSafe(item.updated_at) ?? createdAt;
return {
annotation_id: toStringSafe(item.annotation_id) ?? "",
session_id: toStringSafe(item.session_id) ?? "",
message_id: toStringSafe(item.message_id) ?? "",
message_index: clampInt(toNumberSafe(item.message_index), 0, 100_000, 0),
rating: clampInt(toNumberSafe(item.rating), 1, 5, 3),
comment: parseComment(item.comment),
annotation_author: parseAnnotationAuthor(item.annotation_author),
created_at: createdAt,
updated_at: updatedAt,
context: {
trace_id: toStringSafe(context?.trace_id),
reply_type: toStringSafe(context?.reply_type),
question_text: toStringSafe(context?.question_text),
answer_text: toStringSafe(context?.answer_text)
},
technical_context: technicalContext
} satisfies AssistantAnnotationRecord;
})
.filter((item) => item.annotation_id && item.session_id && item.message_id && item.comment.length > 0)
.sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at));
} catch {
return [];
}
}
function writeAssistantAnnotations(items: AssistantAnnotationRecord[]): void {
ensureAnnotationStoreDir();
fs.writeFileSync(ASSISTANT_ANNOTATIONS_FILE, JSON.stringify(items, null, 2), "utf-8");
}
function annotationKey(sessionId: string, messageIndex: number): string {
return `${sessionId}::${messageIndex}`;
}
export function buildAssistantRouter(services: AppServices): Router { export function buildAssistantRouter(services: AppServices): Router {
const router = Router(); const router = Router();
@ -48,5 +180,111 @@ export function buildAssistantRouter(services: AppServices): Router {
} }
}); });
router.get("/api/assistant/annotations", (req, res, next) => {
try {
const query = req.query as Record<string, unknown>;
const sessionIdFilter = toStringSafe(query.session_id);
const limit = clampInt(toNumberSafe(query.limit), 1, 1000, 300);
const items = readAssistantAnnotations()
.filter((item) => (sessionIdFilter ? item.session_id === sessionIdFilter : true))
.slice(0, limit);
ok(res, {
ok: true,
generated_at: new Date().toISOString(),
filters_applied: {
session_id: sessionIdFilter ?? null,
limit
},
items
});
} catch (error) {
next(error);
}
});
router.post("/api/assistant/annotations", (req, res, next) => {
try {
const body = toRecord(req.body);
if (!body) {
throw new ApiError("INVALID_ASSISTANT_ANNOTATION_PAYLOAD", "JSON body is required", 400);
}
const sessionId = toStringSafe(body.session_id);
const messageIndexRaw = toNumberSafe(body.message_index);
const ratingRaw = toNumberSafe(body.rating);
const comment = parseComment(body.comment);
const annotationAuthor = parseAnnotationAuthor(body.annotation_author);
if (!sessionId) {
throw new ApiError("INVALID_ASSISTANT_ANNOTATION_PAYLOAD", "session_id is required", 400);
}
if (messageIndexRaw === null) {
throw new ApiError("INVALID_ASSISTANT_ANNOTATION_PAYLOAD", "message_index is required", 400);
}
const messageIndex = clampInt(messageIndexRaw, 0, 100_000, 0);
if (ratingRaw === null) {
throw new ApiError("INVALID_ASSISTANT_ANNOTATION_PAYLOAD", "rating is required", 400);
}
const rating = clampInt(ratingRaw, 1, 5, 3);
if (!comment) {
throw new ApiError("INVALID_ASSISTANT_ANNOTATION_PAYLOAD", "comment is required", 400);
}
const session = services.assistantService.getSession(sessionId);
if (!session) {
throw new ApiError("ASSISTANT_SESSION_NOT_FOUND", `Session not found: ${sessionId}`, 404);
}
if (messageIndex >= session.items.length) {
throw new ApiError("ASSISTANT_MESSAGE_NOT_FOUND", `Message index ${messageIndex} out of range`, 400);
}
const targetMessage = session.items[messageIndex];
if (!targetMessage || targetMessage.role !== "assistant") {
throw new ApiError("ASSISTANT_MESSAGE_NOT_ASSISTANT", "Only assistant answers can be annotated", 400);
}
const pairedUserQuestion = [...session.items.slice(0, messageIndex)].reverse().find((item) => item.role === "user") ?? null;
const nowIso = new Date().toISOString();
const annotations = readAssistantAnnotations();
const key = annotationKey(sessionId, messageIndex);
const existingIndex = annotations.findIndex((item) => annotationKey(item.session_id, item.message_index) === key);
const existing = existingIndex >= 0 ? annotations[existingIndex] : null;
const annotation: AssistantAnnotationRecord = {
annotation_id: existing?.annotation_id ?? generateAnnotationId(),
session_id: sessionId,
message_id: targetMessage.message_id,
message_index: messageIndex,
rating,
comment,
annotation_author: annotationAuthor,
created_at: existing?.created_at ?? nowIso,
updated_at: nowIso,
context: {
trace_id: toStringSafe(targetMessage.trace_id),
reply_type: toStringSafe(targetMessage.reply_type),
question_text: toStringSafe(pairedUserQuestion?.text),
answer_text: toStringSafe(targetMessage.text)
},
technical_context: toRecord(targetMessage.debug)
};
if (existingIndex >= 0) {
annotations[existingIndex] = annotation;
} else {
annotations.push(annotation);
}
writeAssistantAnnotations(annotations);
ok(res, {
ok: true,
annotation
});
} catch (error) {
next(error);
}
});
return router; return router;
} }

View File

@ -9,6 +9,7 @@ import {
REPORTS_DIR, REPORTS_DIR,
TIMEZONE, TIMEZONE,
ASSISTANT_SESSIONS_DIR, ASSISTANT_SESSIONS_DIR,
ASSISTANT_ANNOTATIONS_DIR,
AUTORUN_ANNOTATIONS_DIR, AUTORUN_ANNOTATIONS_DIR,
AUTORUN_GENERATOR_DIR AUTORUN_GENERATOR_DIR
} from "./config"; } from "./config";
@ -37,6 +38,7 @@ export function createApp(): express.Express {
ensureDir(EVAL_CASES_DIR); ensureDir(EVAL_CASES_DIR);
ensureDir(REPORTS_DIR); ensureDir(REPORTS_DIR);
ensureDir(ASSISTANT_SESSIONS_DIR); ensureDir(ASSISTANT_SESSIONS_DIR);
ensureDir(ASSISTANT_ANNOTATIONS_DIR);
ensureDir(AUTORUN_ANNOTATIONS_DIR); ensureDir(AUTORUN_ANNOTATIONS_DIR);
ensureDir(AUTORUN_GENERATOR_DIR); ensureDir(AUTORUN_GENERATOR_DIR);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NDC AI Normalizer Playground</title> <title>NDC AI Normalizer Playground</title>
<script type="module" crossorigin src="/assets/index-DMMD5-xN.js"></script> <script type="module" crossorigin src="/assets/index-B_Dz87Mp.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BfGkpjEM.css"> <link rel="stylesheet" crossorigin href="/assets/index-D4dtBq8A.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { apiClient } from "./api/client"; import { apiClient } from "./api/client";
import { AssistantSamPanel } from "./components/AssistantSamPanel"; import { AssistantSamPanel } from "./components/AssistantSamPanel";
import { AutoRunsHistoryPanel } from "./components/AutoRunsHistoryPanel"; import { AutoRunsHistoryPanel } from "./components/AutoRunsHistoryPanel";
@ -7,6 +7,7 @@ import { ConnectionPanel } from "./components/ConnectionPanel";
import { HistoryPanel } from "./components/HistoryPanel"; import { HistoryPanel } from "./components/HistoryPanel";
import { MetricsPanel } from "./components/MetricsPanel"; import { MetricsPanel } from "./components/MetricsPanel";
import { OutputPanel } from "./components/OutputPanel"; import { OutputPanel } from "./components/OutputPanel";
import { PanelFrame } from "./components/PanelFrame";
import { PromptPanel } from "./components/PromptPanel"; import { PromptPanel } from "./components/PromptPanel";
import { QueryPanel } from "./components/QueryPanel"; import { QueryPanel } from "./components/QueryPanel";
import { RuntimePanel } from "./components/RuntimePanel"; import { RuntimePanel } from "./components/RuntimePanel";
@ -14,6 +15,7 @@ import { DEFAULT_CONNECTION, DEFAULT_PROMPTS, DEFAULT_QUERY } from "./state/defa
import { designConfig } from "../../../designconfig"; import { designConfig } from "../../../designconfig";
import type { import type {
AssistantConversationItem, AssistantConversationItem,
AssistantAnnotationRecord,
ConnectionState, ConnectionState,
HistoryItem, HistoryItem,
NormalizeResultState, NormalizeResultState,
@ -32,6 +34,17 @@ const DEFAULT_UI_MODE: UiMode = "assistant";
const AUTOLOAD_PROMPT_VERSION = "normalizer_v2_0_2"; const AUTOLOAD_PROMPT_VERSION = "normalizer_v2_0_2";
const ASSISTANT_PROMPT_VERSION = "address_query_runtime_v1"; const ASSISTANT_PROMPT_VERSION = "address_query_runtime_v1";
const TAB_KEYS: TabKey[] = ["normalized", "fragments", "scope", "flags", "route", "raw", "validation", "logs"]; const TAB_KEYS: TabKey[] = ["normalized", "fragments", "scope", "flags", "route", "raw", "validation", "logs"];
const DEFAULT_ASSISTANT_ANNOTATION_AUTHOR = "manual_reviewer";
interface AssistantCommentModalState {
open: boolean;
messageIndex: number;
rating: number;
comment: string;
annotationAuthor: string;
saving: boolean;
error: string;
}
function withTs(message: string): string { function withTs(message: string): string {
return `[${new Date().toLocaleTimeString("ru-RU")}] ${message}`; return `[${new Date().toLocaleTimeString("ru-RU")}] ${message}`;
@ -91,6 +104,7 @@ export default function App() {
const [showAssistantConnectionMode, setShowAssistantConnectionMode] = useState(true); const [showAssistantConnectionMode, setShowAssistantConnectionMode] = useState(true);
const [showAssistantPromptMode, setShowAssistantPromptMode] = useState(true); const [showAssistantPromptMode, setShowAssistantPromptMode] = useState(true);
const [showAssistantChatMode, setShowAssistantChatMode] = useState(true); const [showAssistantChatMode, setShowAssistantChatMode] = useState(true);
const [showAssistantCommentsMode, setShowAssistantCommentsMode] = useState(true);
const [showAssistantSamMode, setShowAssistantSamMode] = useState(true); const [showAssistantSamMode, setShowAssistantSamMode] = useState(true);
const [showDecompositionConnectionMode, setShowDecompositionConnectionMode] = useState(true); const [showDecompositionConnectionMode, setShowDecompositionConnectionMode] = useState(true);
const [showDecompositionPromptMode, setShowDecompositionPromptMode] = useState(true); const [showDecompositionPromptMode, setShowDecompositionPromptMode] = useState(true);
@ -105,6 +119,17 @@ export default function App() {
const [assistantBusy, setAssistantBusy] = useState(false); const [assistantBusy, setAssistantBusy] = useState(false);
const [assistantStatus, setAssistantStatus] = useState(""); const [assistantStatus, setAssistantStatus] = useState("");
const [assistantError, setAssistantError] = useState(""); const [assistantError, setAssistantError] = useState("");
const [assistantAnnotations, setAssistantAnnotations] = useState<AssistantAnnotationRecord[]>([]);
const [assistantAnnotationsBusy, setAssistantAnnotationsBusy] = useState(false);
const [assistantCommentModal, setAssistantCommentModal] = useState<AssistantCommentModalState>({
open: false,
messageIndex: -1,
rating: 3,
comment: "",
annotationAuthor: DEFAULT_ASSISTANT_ANNOTATION_AUTHOR,
saving: false,
error: ""
});
const presetAutoloadDoneRef = useRef(false); const presetAutoloadDoneRef = useRef(false);
const skipPresetAutoloadRef = useRef(false); const skipPresetAutoloadRef = useRef(false);
@ -172,6 +197,7 @@ export default function App() {
showAssistantConnectionMode?: boolean; showAssistantConnectionMode?: boolean;
showAssistantPromptMode?: boolean; showAssistantPromptMode?: boolean;
showAssistantChatMode?: boolean; showAssistantChatMode?: boolean;
showAssistantCommentsMode?: boolean;
showAssistantSamMode?: boolean; showAssistantSamMode?: boolean;
showDecompositionConnectionMode?: boolean; showDecompositionConnectionMode?: boolean;
showDecompositionPromptMode?: boolean; showDecompositionPromptMode?: boolean;
@ -209,6 +235,9 @@ export default function App() {
if (typeof parsed.showAssistantChatMode === "boolean") { if (typeof parsed.showAssistantChatMode === "boolean") {
setShowAssistantChatMode(parsed.showAssistantChatMode); setShowAssistantChatMode(parsed.showAssistantChatMode);
} }
if (typeof parsed.showAssistantCommentsMode === "boolean") {
setShowAssistantCommentsMode(parsed.showAssistantCommentsMode);
}
if (typeof parsed.showAssistantSamMode === "boolean") { if (typeof parsed.showAssistantSamMode === "boolean") {
setShowAssistantSamMode(parsed.showAssistantSamMode); setShowAssistantSamMode(parsed.showAssistantSamMode);
} }
@ -331,6 +360,7 @@ export default function App() {
showAssistantConnectionMode, showAssistantConnectionMode,
showAssistantPromptMode, showAssistantPromptMode,
showAssistantChatMode, showAssistantChatMode,
showAssistantCommentsMode,
showAssistantSamMode, showAssistantSamMode,
showDecompositionConnectionMode, showDecompositionConnectionMode,
showDecompositionPromptMode, showDecompositionPromptMode,
@ -619,12 +649,146 @@ export default function App() {
} }
} }
const assistantAnnotationsByMessageId = useMemo(() => {
const map = new Map<string, AssistantAnnotationRecord>();
for (const item of assistantAnnotations) {
if (item.message_id) {
map.set(item.message_id, item);
}
}
return map;
}, [assistantAnnotations]);
const assistantCommentModalMessage =
assistantCommentModal.messageIndex >= 0 ? assistantConversation[assistantCommentModal.messageIndex] ?? null : null;
const assistantCommentModalQuestion = useMemo(() => {
if (assistantCommentModal.messageIndex < 0) return null;
for (let index = assistantCommentModal.messageIndex - 1; index >= 0; index -= 1) {
const candidate = assistantConversation[index];
if (candidate?.role === "user") {
return candidate;
}
}
return null;
}, [assistantCommentModal.messageIndex, assistantConversation]);
async function loadAssistantAnnotationsForSession(sessionId: string): Promise<void> {
if (!sessionId.trim()) {
setAssistantAnnotations([]);
return;
}
setAssistantAnnotationsBusy(true);
try {
const payload = await apiClient.loadAssistantAnnotations({
session_id: sessionId,
limit: 400
});
setAssistantAnnotations(payload.items ?? []);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log(`Assistant annotations load error: ${message}`);
} finally {
setAssistantAnnotationsBusy(false);
}
}
function closeAssistantCommentModal(options?: { force?: boolean }) {
setAssistantCommentModal((prev) => {
if (prev.saving && !options?.force) {
return prev;
}
return {
open: false,
messageIndex: -1,
rating: 3,
comment: "",
annotationAuthor: DEFAULT_ASSISTANT_ANNOTATION_AUTHOR,
saving: false,
error: ""
};
});
}
function openAssistantCommentModal(item: AssistantConversationItem, index: number): void {
if (item.role !== "assistant") return;
const sessionIdFromState = assistantSessionId.trim();
const sessionIdFromItem = String(item.session_id ?? "").trim();
const resolvedSessionId = sessionIdFromState || sessionIdFromItem;
if (!resolvedSessionId) {
setAssistantError("Сначала получите ответ ассистента в активной сессии.");
return;
}
if (!sessionIdFromState && sessionIdFromItem) {
setAssistantSessionId(sessionIdFromItem);
}
const existing = assistantAnnotationsByMessageId.get(item.message_id) ?? null;
setAssistantCommentModal({
open: true,
messageIndex: index,
rating: existing?.rating ?? 3,
comment: existing?.comment ?? "",
annotationAuthor: existing?.annotation_author ?? DEFAULT_ASSISTANT_ANNOTATION_AUTHOR,
saving: false,
error: ""
});
}
function canCommentAssistantMessage(item: AssistantConversationItem): boolean {
return item.role === "assistant";
}
function isAssistantMessageCommented(item: AssistantConversationItem): boolean {
return item.role === "assistant" && assistantAnnotationsByMessageId.has(item.message_id);
}
async function submitAssistantCommentModal(): Promise<void> {
if (!assistantSessionId.trim()) {
setAssistantCommentModal((prev) => ({ ...prev, error: "Сессия ассистента не найдена." }));
return;
}
if (assistantCommentModal.messageIndex < 0) {
return;
}
if (!assistantCommentModal.comment.trim()) {
setAssistantCommentModal((prev) => ({ ...prev, error: "Добавьте комментарий." }));
return;
}
setAssistantCommentModal((prev) => ({ ...prev, saving: true, error: "" }));
try {
const payload = await apiClient.saveAssistantAnnotation({
session_id: assistantSessionId,
message_index: assistantCommentModal.messageIndex,
rating: assistantCommentModal.rating,
comment: assistantCommentModal.comment.trim(),
annotation_author: assistantCommentModal.annotationAuthor.trim() || undefined
});
setAssistantAnnotations((prev) => {
const next = [...prev];
const index = next.findIndex((item) => item.annotation_id === payload.annotation.annotation_id);
if (index >= 0) {
next[index] = payload.annotation;
} else {
next.unshift(payload.annotation);
}
return next.sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at));
});
closeAssistantCommentModal({ force: true });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAssistantCommentModal((prev) => ({ ...prev, saving: false, error: message }));
}
}
function resetAssistantSession() { function resetAssistantSession() {
setAssistantSessionId(""); setAssistantSessionId("");
setAssistantConversation([]); setAssistantConversation([]);
setAssistantInput(""); setAssistantInput("");
setAssistantStatus(""); setAssistantStatus("");
setAssistantError(""); setAssistantError("");
setAssistantAnnotations([]);
closeAssistantCommentModal({ force: true });
log("Assistant session reset."); log("Assistant session reset.");
} }
@ -664,6 +828,7 @@ export default function App() {
setAssistantSessionId(response.session_id); setAssistantSessionId(response.session_id);
setAssistantConversation(response.conversation); setAssistantConversation(response.conversation);
setAssistantStatus("Ответ готов"); setAssistantStatus("Ответ готов");
await loadAssistantAnnotationsForSession(response.session_id);
log(`Assistant reply received: trace=${response.debug.trace_id}`); log(`Assistant reply received: trace=${response.debug.trace_id}`);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
@ -676,6 +841,14 @@ export default function App() {
} }
} }
useEffect(() => {
if (!assistantSessionId.trim()) {
setAssistantAnnotations([]);
return;
}
void loadAssistantAnnotationsForSession(assistantSessionId);
}, [assistantSessionId]);
useEffect(() => { useEffect(() => {
if (!selectedRunId) { if (!selectedRunId) {
setRunTrace([]); setRunTrace([]);
@ -728,6 +901,13 @@ export default function App() {
<button type="button" className={showAssistantChatMode ? "tab active" : "tab"} onClick={() => setShowAssistantChatMode((prev) => !prev)}> <button type="button" className={showAssistantChatMode ? "tab active" : "tab"} onClick={() => setShowAssistantChatMode((prev) => !prev)}>
Режим ассистента Режим ассистента
</button> </button>
<button
type="button"
className={showAssistantCommentsMode ? "tab active" : "tab"}
onClick={() => setShowAssistantCommentsMode((prev) => !prev)}
>
Комментарии ассистента
</button>
<button type="button" className={showAssistantSamMode ? "tab active" : "tab"} onClick={() => setShowAssistantSamMode((prev) => !prev)}> <button type="button" className={showAssistantSamMode ? "tab active" : "tab"} onClick={() => setShowAssistantSamMode((prev) => !prev)}>
SAM SAM
</button> </button>
@ -862,10 +1042,57 @@ export default function App() {
busy={assistantBusy} busy={assistantBusy}
statusText={assistantStatus} statusText={assistantStatus}
errorMessage={assistantError} errorMessage={assistantError}
showCommentAction
onCommentAssistantMessage={openAssistantCommentModal}
isAssistantMessageCommented={isAssistantMessageCommented}
canCommentAssistantMessage={canCommentAssistantMessage}
/> />
</div> </div>
) : null} ) : null}
{showAssistantCommentsMode ? (
<div className="mode-col">
<PanelFrame className="assistant-comments-frame" title="Комментарии ассистента">
<div className="assistant-comments-shell">
<div className="assistant-comments-toolbar">
<span className="muted">
{assistantSessionId ? `session: ${assistantSessionId}` : "Сессия не запущена"}
</span>
<button
type="button"
className="tab"
onClick={() => void loadAssistantAnnotationsForSession(assistantSessionId)}
disabled={!assistantSessionId || assistantAnnotationsBusy}
>
{assistantAnnotationsBusy ? "Обновляю..." : "Обновить"}
</button>
</div>
<div className="assistant-comments-list">
{!assistantSessionId ? <p className="muted">Появится после первого ответа ассистента.</p> : null}
{assistantSessionId && assistantAnnotations.length === 0 && !assistantAnnotationsBusy ? (
<p className="muted">Комментариев по этой сессии пока нет.</p>
) : null}
{assistantAnnotations.map((item) => (
<article key={item.annotation_id} className="assistant-comment-item">
<div className="assistant-comment-head">
<strong>{`${"●".repeat(Math.max(1, Math.min(5, Math.round(item.rating))))}${"○".repeat(Math.max(0, 5 - Math.round(item.rating)))}`}</strong>
<span>{new Date(item.updated_at).toLocaleString("ru-RU")}</span>
</div>
{item.context.question_text ? <p>Q: {item.context.question_text}</p> : null}
{item.context.answer_text ? <p>A: {item.context.answer_text}</p> : null}
<p>{item.comment}</p>
<div className="assistant-comment-meta">
{item.context.trace_id ? <span>{`trace=${item.context.trace_id}`}</span> : null}
{item.context.reply_type ? <span>{`reply_type=${item.context.reply_type}`}</span> : null}
</div>
</article>
))}
</div>
</div>
</PanelFrame>
</div>
) : null}
{showAssistantSamMode ? ( {showAssistantSamMode ? (
<div className="mode-col"> <div className="mode-col">
<AssistantSamPanel <AssistantSamPanel
@ -879,7 +1106,11 @@ export default function App() {
</div> </div>
) : null} ) : null}
{!showAssistantConnectionMode && !showAssistantPromptMode && !showAssistantChatMode && !showAssistantSamMode ? ( {!showAssistantConnectionMode &&
!showAssistantPromptMode &&
!showAssistantChatMode &&
!showAssistantCommentsMode &&
!showAssistantSamMode ? (
<div className="mode-columns-empty">Все панели режима ассистента скрыты. Включите нужные блоки справа в шапке.</div> <div className="mode-columns-empty">Все панели режима ассистента скрыты. Включите нужные блоки справа в шапке.</div>
) : null} ) : null}
</div> </div>
@ -999,6 +1230,89 @@ export default function App() {
/> />
</div> </div>
)} )}
{assistantCommentModal.open ? (
<div
className="autoruns-comment-modal-backdrop"
onClick={(event) => {
if (event.target === event.currentTarget) {
closeAssistantCommentModal();
}
}}
>
<div className="autoruns-comment-modal">
<h3>Комментарий к ответу ассистента</h3>
<p className="muted">Эта разметка хранится отдельно от комментариев автопрогонов.</p>
{assistantCommentModalQuestion ? (
<details className="autoruns-prompt-details" open>
<summary>Вопрос пользователя</summary>
<p className="autoruns-comment-quote">{assistantCommentModalQuestion.text}</p>
</details>
) : null}
{assistantCommentModalMessage ? (
<details className="autoruns-prompt-details" open>
<summary>Ответ ассистента</summary>
<p className="autoruns-comment-quote">{assistantCommentModalMessage.text}</p>
</details>
) : null}
<div className="autoruns-rating-row" role="group" aria-label="Рейтинг ответа">
{[1, 2, 3, 4, 5].map((value) => (
<button
key={value}
type="button"
className={assistantCommentModal.rating >= value ? "autoruns-rating-dot active" : "autoruns-rating-dot"}
onClick={() => setAssistantCommentModal((prev) => ({ ...prev, rating: value }))}
disabled={assistantCommentModal.saving}
aria-label={`Оценка ${value}`}
>
{assistantCommentModal.rating >= value ? "●" : "○"}
</button>
))}
</div>
<div className="autoruns-form-grid">
<label>
Автор комментария
<input
value={assistantCommentModal.annotationAuthor}
onChange={(event) => setAssistantCommentModal((prev) => ({ ...prev, annotationAuthor: event.target.value }))}
placeholder="manual_reviewer"
disabled={assistantCommentModal.saving}
/>
</label>
</div>
<label>
Комментарий
<textarea
value={assistantCommentModal.comment}
onChange={(event) => setAssistantCommentModal((prev) => ({ ...prev, comment: event.target.value }))}
placeholder="Что именно не так в ответе и что проверить."
rows={4}
disabled={assistantCommentModal.saving}
/>
</label>
{assistantCommentModal.error ? <p className="error-text">{assistantCommentModal.error}</p> : null}
<div className="button-row">
<button type="button" onClick={() => void submitAssistantCommentModal()} disabled={assistantCommentModal.saving}>
{assistantCommentModal.saving ? "Сохраняю..." : "Готово"}
</button>
<button
type="button"
className="tab"
onClick={() => closeAssistantCommentModal()}
disabled={assistantCommentModal.saving}
>
Отмена
</button>
</div>
</div>
</div>
) : null}
</main> </main>
); );
} }

View File

@ -10,6 +10,8 @@ import type {
AutoRunDialogResponse, AutoRunDialogResponse,
AutoRunHistoryResponse, AutoRunHistoryResponse,
AutoRunPostAnalysisResponse, AutoRunPostAnalysisResponse,
AssistantAnnotationsResponse,
AssistantAnnotationRecord,
AssistantMessageResultState, AssistantMessageResultState,
AssistantConversationItem, AssistantConversationItem,
ConnectionState, ConnectionState,
@ -314,6 +316,30 @@ export const apiClient = {
return request(`/assistant/session/${sessionId}`); return request(`/assistant/session/${sessionId}`);
}, },
async loadAssistantAnnotations(input?: {
session_id?: string;
limit?: number;
}): Promise<AssistantAnnotationsResponse> {
const params = new URLSearchParams();
if (input?.session_id) params.set("session_id", input.session_id);
if (typeof input?.limit === "number") params.set("limit", String(input.limit));
const query = params.toString();
return request(`/assistant/annotations${query ? `?${query}` : ""}`);
},
async saveAssistantAnnotation(input: {
session_id: string;
message_index: number;
rating: number;
comment: string;
annotation_author?: string;
}): Promise<{ ok: boolean; annotation: AssistantAnnotationRecord }> {
return request("/assistant/annotations", {
method: "POST",
body: JSON.stringify(input)
});
},
async loadAutoRunsHistory(input?: { async loadAutoRunsHistory(input?: {
from?: string; from?: string;
to?: string; to?: string;

View File

@ -16,6 +16,10 @@ interface AssistantPanelProps {
busy: boolean; busy: boolean;
statusText: string; statusText: string;
errorMessage: string; errorMessage: string;
showCommentAction?: boolean;
onCommentAssistantMessage?: (item: AssistantConversationItem, index: number) => void;
isAssistantMessageCommented?: (item: AssistantConversationItem, index: number) => boolean;
canCommentAssistantMessage?: (item: AssistantConversationItem, index: number) => boolean;
} }
function roleLabel(role: AssistantConversationItem["role"]): string { function roleLabel(role: AssistantConversationItem["role"]): string {
@ -61,6 +65,15 @@ async function copyTextToClipboard(text: string): Promise<boolean> {
return copied; return copied;
} }
function CommentBubbleIcon({ commented }: { commented: boolean }) {
const className = commented ? "comment-icon-svg commented" : "comment-icon-svg";
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M5 6.5h14v9H11.5l-4.5 3v-3H5z" />
</svg>
);
}
export function AssistantPanel({ export function AssistantPanel({
sessionId, sessionId,
conversation, conversation,
@ -72,7 +85,11 @@ export function AssistantPanel({
onClear, onClear,
busy, busy,
statusText, statusText,
errorMessage errorMessage,
showCommentAction = false,
onCommentAssistantMessage,
isAssistantMessageCommented,
canCommentAssistantMessage
}: AssistantPanelProps) { }: AssistantPanelProps) {
const listRef = useRef<HTMLDivElement | null>(null); const listRef = useRef<HTMLDivElement | null>(null);
const stickToBottomRef = useRef(true); const stickToBottomRef = useRef(true);
@ -162,11 +179,45 @@ export function AssistantPanel({
</div> </div>
<div ref={listRef} className="assistant-chat-list" onScroll={handleChatScroll}> <div ref={listRef} className="assistant-chat-list" onScroll={handleChatScroll}>
{conversation.map((item) => ( {conversation.map((item, index) => {
const commentEnabled =
item.role === "assistant" &&
showCommentAction &&
typeof onCommentAssistantMessage === "function" &&
(typeof canCommentAssistantMessage === "function" ? canCommentAssistantMessage(item, index) : true);
const commented =
item.role === "assistant" && typeof isAssistantMessageCommented === "function"
? isAssistantMessageCommented(item, index)
: false;
return (
<article key={item.message_id} className={`assistant-msg ${item.role}`}> <article key={item.message_id} className={`assistant-msg ${item.role}`}>
<header className="assistant-msg-head"> <header className="assistant-msg-head">
<strong>{roleLabel(item.role)}</strong> <div className="assistant-msg-head-main">
<span>{shortTime(item.created_at)}</span> <strong>{roleLabel(item.role)}</strong>
<span>{shortTime(item.created_at)}</span>
</div>
{item.role === "assistant" && showCommentAction ? (
<div className="assistant-msg-head-actions">
<button
type="button"
className={commented ? "autoruns-comment-icon assistant-comment-btn commented" : "autoruns-comment-icon assistant-comment-btn"}
onClick={() => onCommentAssistantMessage?.(item, index)}
disabled={!commentEnabled}
title={
commentEnabled
? "Комментировать ответ ассистента"
: "Комментарий недоступен для этого сообщения"
}
aria-label={
commentEnabled
? "Комментировать ответ ассистента"
: "Комментарий недоступен для этого сообщения"
}
>
<CommentBubbleIcon commented={commented} />
</button>
</div>
) : null}
</header> </header>
<div className="assistant-msg-body">{item.text}</div> <div className="assistant-msg-body">{item.text}</div>
{item.role === "assistant" && item.debug ? ( {item.role === "assistant" && item.debug ? (
@ -176,13 +227,15 @@ export function AssistantPanel({
</details> </details>
) : null} ) : null}
</article> </article>
))} );
})}
</div> </div>
<div className="assistant-compose"> <div className="assistant-compose">
<label className="full-width"> <label className="full-width">
Сообщение Сообщение
<textarea <textarea
className="assistant-input-textarea"
value={inputValue} value={inputValue}
onChange={(event) => onInputChange(event.target.value)} onChange={(event) => onInputChange(event.target.value)}
rows={4} rows={4}

View File

@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type SyntheticEvent } from "react";
import { apiClient } from "../api/client"; import { apiClient } from "../api/client";
import type { import type {
AssistantConversationItem, AssistantConversationItem,
AssistantAnnotationRecord,
AsyncEvalRunJob, AsyncEvalRunJob,
AutoGenHistoryRecord, AutoGenHistoryRecord,
AutoGenMode, AutoGenMode,
@ -68,6 +69,16 @@ interface CommentModalState {
error: string; error: string;
} }
interface AssistantLiveCommentModalState {
open: boolean;
messageIndex: number;
rating: number;
comment: string;
annotationAuthor: string;
saving: boolean;
error: string;
}
interface AutoGenSettingsState { interface AutoGenSettingsState {
mode: AutoGenMode; mode: AutoGenMode;
count: number; count: number;
@ -117,6 +128,7 @@ function buildDefaultPersonalityPrompts(
interface AutoRunsUiConfig { interface AutoRunsUiConfig {
filters?: Partial<AutoRunsFilters>; filters?: Partial<AutoRunsFilters>;
analysisDate?: string; analysisDate?: string;
autogenPersonalityPromptHeight?: number;
autoGenSettings?: { autoGenSettings?: {
mode?: AutoGenMode; mode?: AutoGenMode;
count?: number; count?: number;
@ -143,6 +155,11 @@ function normalizeAnalysisDateInput(value: string): string {
return /^\d{4}-\d{2}-\d{2}$/.test(normalized) ? normalized : ""; return /^\d{4}-\d{2}-\d{2}$/.test(normalized) ? normalized : "";
} }
function clampAutogenPromptHeight(value: number | null | undefined): number {
const numeric = typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : 160;
return Math.max(110, Math.min(520, numeric));
}
function dateToInputValue(date: Date): string { function dateToInputValue(date: Date): string {
const year = date.getFullYear(); const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0"); const month = String(date.getMonth() + 1).padStart(2, "0");
@ -391,9 +408,6 @@ function CommentBubbleIcon({ commented }: { commented: boolean }) {
return ( return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false"> <svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M5 6.5h14v9H11.5l-4.5 3v-3H5z" /> <path d="M5 6.5h14v9H11.5l-4.5 3v-3H5z" />
<circle className="comment-icon-dot" cx="9" cy="11" r="1.05" />
<circle className="comment-icon-dot" cx="12" cy="11" r="1.05" />
<circle className="comment-icon-dot" cx="15" cy="11" r="1.05" />
</svg> </svg>
); );
} }
@ -462,6 +476,7 @@ export function AutoRunsHistoryPanel({
const [errorText, setErrorText] = useState(""); const [errorText, setErrorText] = useState("");
const [assistantLiveSessionId, setAssistantLiveSessionId] = useState(""); const [assistantLiveSessionId, setAssistantLiveSessionId] = useState("");
const [assistantLiveConversation, setAssistantLiveConversation] = useState<AssistantConversationItem[]>([]); const [assistantLiveConversation, setAssistantLiveConversation] = useState<AssistantConversationItem[]>([]);
const [assistantLiveAnnotations, setAssistantLiveAnnotations] = useState<AssistantAnnotationRecord[]>([]);
const [assistantLiveInput, setAssistantLiveInput] = useState(""); const [assistantLiveInput, setAssistantLiveInput] = useState("");
const [assistantLiveUseMock, setAssistantLiveUseMock] = useState(false); const [assistantLiveUseMock, setAssistantLiveUseMock] = useState(false);
const [assistantLiveBusy, setAssistantLiveBusy] = useState(false); const [assistantLiveBusy, setAssistantLiveBusy] = useState(false);
@ -469,6 +484,7 @@ export function AutoRunsHistoryPanel({
const [assistantLiveError, setAssistantLiveError] = useState(""); const [assistantLiveError, setAssistantLiveError] = useState("");
const [limitInput, setLimitInput] = useState(String(DEFAULT_FILTERS.limit)); const [limitInput, setLimitInput] = useState(String(DEFAULT_FILTERS.limit));
const [autogenCountInput, setAutogenCountInput] = useState(String(DEFAULT_AUTOGEN_SETTINGS.count)); const [autogenCountInput, setAutogenCountInput] = useState(String(DEFAULT_AUTOGEN_SETTINGS.count));
const [autogenPersonalityPromptHeight, setAutogenPersonalityPromptHeight] = useState(160);
const [commentModal, setCommentModal] = useState<CommentModalState>({ const [commentModal, setCommentModal] = useState<CommentModalState>({
open: false, open: false,
caseId: "", caseId: "",
@ -481,6 +497,15 @@ export function AutoRunsHistoryPanel({
saving: false, saving: false,
error: "" error: ""
}); });
const [assistantLiveCommentModal, setAssistantLiveCommentModal] = useState<AssistantLiveCommentModalState>({
open: false,
messageIndex: -1,
rating: 3,
comment: "",
annotationAuthor: "manual_reviewer",
saving: false,
error: ""
});
const initialLoadDoneRef = useRef(false); const initialLoadDoneRef = useRef(false);
const asyncJobPollTimerRef = useRef<number | null>(null); const asyncJobPollTimerRef = useRef<number | null>(null);
@ -510,6 +535,27 @@ export function AutoRunsHistoryPanel({
} }
return null; return null;
}, [commentModal.messageIndex, dialog]); }, [commentModal.messageIndex, dialog]);
const assistantLiveAnnotationsByMessageId = useMemo(() => {
const map = new Map<string, AssistantAnnotationRecord>();
for (const item of assistantLiveAnnotations) {
if (item.message_id) {
map.set(item.message_id, item);
}
}
return map;
}, [assistantLiveAnnotations]);
const assistantLiveCommentModalMessage =
assistantLiveCommentModal.messageIndex >= 0 ? assistantLiveConversation[assistantLiveCommentModal.messageIndex] ?? null : null;
const assistantLiveCommentModalQuestion = useMemo(() => {
if (assistantLiveCommentModal.messageIndex < 0) return null;
for (let index = assistantLiveCommentModal.messageIndex - 1; index >= 0; index -= 1) {
const candidate = assistantLiveConversation[index];
if (candidate?.role === "user") {
return candidate;
}
}
return null;
}, [assistantLiveCommentModal.messageIndex, assistantLiveConversation]);
const annotationsAverageRating = useMemo(() => { const annotationsAverageRating = useMemo(() => {
if (visibleAnnotations.length === 0) return null; if (visibleAnnotations.length === 0) return null;
@ -535,6 +581,44 @@ export function AutoRunsHistoryPanel({
[onLog] [onLog]
); );
const loadAssistantLiveAnnotationsForSession = useCallback(
async (sessionIdRaw: string) => {
const sessionId = String(sessionIdRaw ?? "").trim();
if (!sessionId) {
setAssistantLiveAnnotations([]);
return;
}
try {
const payload = await apiClient.loadAssistantAnnotations({
session_id: sessionId,
limit: 400
});
setAssistantLiveAnnotations(payload.items ?? []);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log(`Assistant live annotations load error: ${message}`);
}
},
[log]
);
const closeAssistantLiveCommentModal = useCallback((options?: { force?: boolean }) => {
setAssistantLiveCommentModal((prev) => {
if (prev.saving && !options?.force) {
return prev;
}
return {
open: false,
messageIndex: -1,
rating: 3,
comment: "",
annotationAuthor: "manual_reviewer",
saving: false,
error: ""
};
});
}, []);
const copyRunIdToClipboard = useCallback( const copyRunIdToClipboard = useCallback(
async (event: React.SyntheticEvent, runId: string) => { async (event: React.SyntheticEvent, runId: string) => {
event.stopPropagation(); event.stopPropagation();
@ -580,11 +664,13 @@ export function AutoRunsHistoryPanel({
const resetAssistantLiveSession = useCallback(() => { const resetAssistantLiveSession = useCallback(() => {
setAssistantLiveSessionId(""); setAssistantLiveSessionId("");
setAssistantLiveConversation([]); setAssistantLiveConversation([]);
setAssistantLiveAnnotations([]);
setAssistantLiveInput(""); setAssistantLiveInput("");
setAssistantLiveStatus(""); setAssistantLiveStatus("");
setAssistantLiveError(""); setAssistantLiveError("");
closeAssistantLiveCommentModal({ force: true });
log("Live-чат ассистента в истории автопрогонов сброшен."); log("Live-чат ассистента в истории автопрогонов сброшен.");
}, [log]); }, [closeAssistantLiveCommentModal, log]);
const sendAssistantLiveMessage = useCallback(async () => { const sendAssistantLiveMessage = useCallback(async () => {
const userMessage = assistantLiveInput.trim(); const userMessage = assistantLiveInput.trim();
@ -621,6 +707,7 @@ export function AutoRunsHistoryPanel({
}); });
setAssistantLiveSessionId(response.session_id); setAssistantLiveSessionId(response.session_id);
setAssistantLiveConversation(response.conversation); setAssistantLiveConversation(response.conversation);
await loadAssistantLiveAnnotationsForSession(response.session_id);
setAssistantLiveStatus("Ответ готов"); setAssistantLiveStatus("Ответ готов");
log(`Live-ответ ассистента получен: trace=${response.debug.trace_id}`); log(`Live-ответ ассистента получен: trace=${response.debug.trace_id}`);
} catch (error) { } catch (error) {
@ -638,6 +725,7 @@ export function AutoRunsHistoryPanel({
assistantLiveUseMock, assistantLiveUseMock,
assistantPromptVersion, assistantPromptVersion,
connection, connection,
loadAssistantLiveAnnotationsForSession,
log, log,
prompts prompts
]); ]);
@ -692,6 +780,20 @@ export function AutoRunsHistoryPanel({
[autoGenSettings.count] [autoGenSettings.count]
); );
const commitAutogenPromptHeight = useCallback((height: number) => {
setAutogenPersonalityPromptHeight(clampAutogenPromptHeight(height));
}, []);
const captureAutogenPromptHeight = useCallback(
(event: SyntheticEvent<HTMLTextAreaElement>) => {
const nextHeight = event.currentTarget.offsetHeight;
if (Number.isFinite(nextHeight) && nextHeight > 0) {
commitAutogenPromptHeight(nextHeight);
}
},
[commitAutogenPromptHeight]
);
const loadAnnotations = useCallback(async () => { const loadAnnotations = useCallback(async () => {
setAnnotationsBusy(true); setAnnotationsBusy(true);
try { try {
@ -1202,6 +1304,94 @@ export function AutoRunsHistoryPanel({
selectedRunId selectedRunId
]); ]);
const canCommentAssistantLiveMessage = useCallback((item: AssistantConversationItem): boolean => item.role === "assistant", []);
const isAssistantLiveMessageCommented = useCallback(
(item: AssistantConversationItem): boolean => item.role === "assistant" && assistantLiveAnnotationsByMessageId.has(item.message_id),
[assistantLiveAnnotationsByMessageId]
);
const openAssistantLiveCommentModal = useCallback(
(item: AssistantConversationItem, index: number) => {
if (item.role !== "assistant") {
return;
}
const sessionIdFromState = assistantLiveSessionId.trim();
const sessionIdFromItem = String(item.session_id ?? "").trim();
const resolvedSessionId = sessionIdFromState || sessionIdFromItem;
if (!resolvedSessionId) {
setAssistantLiveError("Сначала получите ответ ассистента в активной сессии.");
return;
}
if (!sessionIdFromState && sessionIdFromItem) {
setAssistantLiveSessionId(sessionIdFromItem);
}
const existing = assistantLiveAnnotationsByMessageId.get(item.message_id) ?? null;
setAssistantLiveError("");
setAssistantLiveCommentModal({
open: true,
messageIndex: index,
rating: existing?.rating ?? 3,
comment: existing?.comment ?? "",
annotationAuthor: existing?.annotation_author ?? "manual_reviewer",
saving: false,
error: ""
});
},
[assistantLiveAnnotationsByMessageId, assistantLiveSessionId]
);
const submitAssistantLiveCommentModal = useCallback(async () => {
if (assistantLiveCommentModal.messageIndex < 0) {
return;
}
if (!assistantLiveCommentModal.comment.trim()) {
setAssistantLiveCommentModal((prev) => ({ ...prev, error: "Добавьте комментарий." }));
return;
}
const modalMessage = assistantLiveConversation[assistantLiveCommentModal.messageIndex] ?? null;
const sessionId =
assistantLiveSessionId.trim() || (modalMessage?.role === "assistant" ? String(modalMessage.session_id ?? "").trim() : "");
if (!sessionId) {
setAssistantLiveCommentModal((prev) => ({ ...prev, error: "Сессия ассистента не найдена." }));
return;
}
setAssistantLiveCommentModal((prev) => ({ ...prev, saving: true, error: "" }));
try {
const payload = await apiClient.saveAssistantAnnotation({
session_id: sessionId,
message_index: assistantLiveCommentModal.messageIndex,
rating: assistantLiveCommentModal.rating,
comment: assistantLiveCommentModal.comment.trim(),
annotation_author: assistantLiveCommentModal.annotationAuthor.trim() || undefined
});
setAssistantLiveAnnotations((prev) => {
const next = [...prev];
const index = next.findIndex((item) => item.annotation_id === payload.annotation.annotation_id);
if (index >= 0) {
next[index] = payload.annotation;
} else {
next.unshift(payload.annotation);
}
return next.sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at));
});
closeAssistantLiveCommentModal({ force: true });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAssistantLiveCommentModal((prev) => ({ ...prev, saving: false, error: message }));
}
}, [
assistantLiveCommentModal.annotationAuthor,
assistantLiveCommentModal.comment,
assistantLiveCommentModal.messageIndex,
assistantLiveCommentModal.rating,
assistantLiveConversation,
assistantLiveSessionId,
closeAssistantLiveCommentModal
]);
const applyLocalAnnotationPatch = useCallback((annotation: AutoRunAnnotationRecord) => { const applyLocalAnnotationPatch = useCallback((annotation: AutoRunAnnotationRecord) => {
setAnnotations((prev) => setAnnotations((prev) =>
prev.map((item) => prev.map((item) =>
@ -1316,6 +1506,14 @@ export function AutoRunsHistoryPanel({
setAutogenCountInput(String(autoGenSettings.count)); setAutogenCountInput(String(autoGenSettings.count));
}, [autoGenSettings.count]); }, [autoGenSettings.count]);
useEffect(() => {
if (!assistantLiveSessionId.trim()) {
setAssistantLiveAnnotations([]);
return;
}
void loadAssistantLiveAnnotationsForSession(assistantLiveSessionId);
}, [assistantLiveSessionId, loadAssistantLiveAnnotationsForSession]);
useEffect(() => { useEffect(() => {
if (!activeAsyncJob) return; if (!activeAsyncJob) return;
const liveRunId = toLiveRunId(activeAsyncJob.job_id); const liveRunId = toLiveRunId(activeAsyncJob.job_id);
@ -1375,6 +1573,9 @@ export function AutoRunsHistoryPanel({
if (typeof parsed.analysisDate === "string") { if (typeof parsed.analysisDate === "string") {
setAnalysisDate(normalizeAnalysisDateInput(parsed.analysisDate)); setAnalysisDate(normalizeAnalysisDateInput(parsed.analysisDate));
} }
if (typeof parsed.autogenPersonalityPromptHeight === "number") {
setAutogenPersonalityPromptHeight(clampAutogenPromptHeight(parsed.autogenPersonalityPromptHeight));
}
if (parsed.autoGenSettings) { if (parsed.autoGenSettings) {
setAutoGenSettings((prev) => { setAutoGenSettings((prev) => {
const nextPrompts: Record<string, string> = { const nextPrompts: Record<string, string> = {
@ -1431,6 +1632,7 @@ export function AutoRunsHistoryPanel({
const payload: AutoRunsUiConfig = { const payload: AutoRunsUiConfig = {
filters, filters,
analysisDate, analysisDate,
autogenPersonalityPromptHeight,
autoGenSettings: { autoGenSettings: {
mode: autoGenSettings.mode, mode: autoGenSettings.mode,
count: autoGenSettings.count, count: autoGenSettings.count,
@ -1443,7 +1645,7 @@ export function AutoRunsHistoryPanel({
hideResolvedAnnotations hideResolvedAnnotations
}; };
localStorage.setItem(AUTORUNS_UI_CONFIG_KEY, JSON.stringify(payload)); localStorage.setItem(AUTORUNS_UI_CONFIG_KEY, JSON.stringify(payload));
}, [analysisDate, annotationDecisionFilter, autoGenSettings, filters, hideResolvedAnnotations]); }, [analysisDate, annotationDecisionFilter, autoGenSettings, autogenPersonalityPromptHeight, filters, hideResolvedAnnotations]);
useEffect(() => { useEffect(() => {
const onSave = () => { const onSave = () => {
@ -1596,9 +1798,6 @@ export function AutoRunsHistoryPanel({
</div> </div>
<h4>Автогенерация вопросов</h4> <h4>Автогенерация вопросов</h4>
<p className="muted">
`qwen_seed` использует текущую LLM-модель из активного контура подключения (та же модель, что и для ответов ассистента).
</p>
<div className="autoruns-form-grid"> <div className="autoruns-form-grid">
<label> <label>
Режим генерации Режим генерации
@ -1660,6 +1859,7 @@ export function AutoRunsHistoryPanel({
<label className="full-width"> <label className="full-width">
Промпт личности Промпт личности
<textarea <textarea
className="autoruns-personality-prompt"
value={autoGenSettings.personalityPrompts[autoGenSettings.personalityId] ?? ""} value={autoGenSettings.personalityPrompts[autoGenSettings.personalityId] ?? ""}
onChange={(event) => onChange={(event) =>
setAutoGenSettings((prev) => ({ setAutoGenSettings((prev) => ({
@ -1671,6 +1871,9 @@ export function AutoRunsHistoryPanel({
})) }))
} }
placeholder="Текст промпта для выбранной личности автогенерации" placeholder="Текст промпта для выбранной личности автогенерации"
style={{ height: `${autogenPersonalityPromptHeight}px` }}
onMouseUp={captureAutogenPromptHeight}
onTouchEnd={captureAutogenPromptHeight}
/> />
</label> </label>
<label className="checkbox-row"> <label className="checkbox-row">
@ -1698,9 +1901,6 @@ export function AutoRunsHistoryPanel({
</button> </button>
</div> </div>
</div> </div>
<p className="muted">
Если дата среза задана, автопрогон анализирует данные на эту дату. Если поле пустое, используется текущее состояние.
</p>
<div className="button-row"> <div className="button-row">
<button type="button" disabled={autoGenBusy} onClick={() => void generateAutogenBatch()}> <button type="button" disabled={autoGenBusy} onClick={() => void generateAutogenBatch()}>
@ -1711,7 +1911,7 @@ export function AutoRunsHistoryPanel({
</button> </button>
<button <button
type="button" type="button"
className="tab" className="autoruns-run-launch-btn"
disabled={autogenRunBusy || editableGeneratedQuestions.length === 0} disabled={autogenRunBusy || editableGeneratedQuestions.length === 0}
onClick={() => void runAutogenCampaign()} onClick={() => void runAutogenCampaign()}
> >
@ -1764,7 +1964,7 @@ export function AutoRunsHistoryPanel({
title="Удалить вопрос из запуска" title="Удалить вопрос из запуска"
aria-label="Удалить вопрос из запуска" aria-label="Удалить вопрос из запуска"
> >
X +
</button> </button>
</div> </div>
))} ))}
@ -2071,6 +2271,10 @@ export function AutoRunsHistoryPanel({
busy={assistantLiveBusy} busy={assistantLiveBusy}
statusText={assistantLiveStatus} statusText={assistantLiveStatus}
errorMessage={assistantLiveError} errorMessage={assistantLiveError}
showCommentAction
onCommentAssistantMessage={openAssistantLiveCommentModal}
isAssistantMessageCommented={isAssistantLiveMessageCommented}
canCommentAssistantMessage={canCommentAssistantLiveMessage}
/> />
</div> </div>
) : null} ) : null}
@ -2359,6 +2563,89 @@ export function AutoRunsHistoryPanel({
) : null} ) : null}
</div> </div>
{assistantLiveCommentModal.open ? (
<div
className="autoruns-comment-modal-backdrop"
onClick={(event) => {
if (event.target === event.currentTarget) {
closeAssistantLiveCommentModal();
}
}}
>
<div className="autoruns-comment-modal">
<h3>Комментарий к ответу ассистента</h3>
<p className="muted">Комментарий сохраняется отдельно от комментариев автопрогонов.</p>
{assistantLiveCommentModalQuestion ? (
<details className="autoruns-prompt-details" open>
<summary>Вопрос пользователя</summary>
<p className="autoruns-comment-quote">{assistantLiveCommentModalQuestion.text}</p>
</details>
) : null}
{assistantLiveCommentModalMessage ? (
<details className="autoruns-prompt-details" open>
<summary>Ответ ассистента</summary>
<p className="autoruns-comment-quote">{assistantLiveCommentModalMessage.text}</p>
</details>
) : null}
<div className="autoruns-rating-row" role="group" aria-label="Рейтинг ответа ассистента">
{[1, 2, 3, 4, 5].map((value) => (
<button
key={value}
type="button"
className={assistantLiveCommentModal.rating >= value ? "autoruns-rating-dot active" : "autoruns-rating-dot"}
onClick={() => setAssistantLiveCommentModal((prev) => ({ ...prev, rating: value }))}
disabled={assistantLiveCommentModal.saving}
aria-label={`Оценка ${value}`}
>
{assistantLiveCommentModal.rating >= value ? "●" : "○"}
</button>
))}
</div>
<div className="autoruns-form-grid">
<label>
Автор комментария
<input
value={assistantLiveCommentModal.annotationAuthor}
onChange={(event) => setAssistantLiveCommentModal((prev) => ({ ...prev, annotationAuthor: event.target.value }))}
placeholder="manual_reviewer"
disabled={assistantLiveCommentModal.saving}
/>
</label>
</div>
<label>
Комментарий
<textarea
value={assistantLiveCommentModal.comment}
onChange={(event) => setAssistantLiveCommentModal((prev) => ({ ...prev, comment: event.target.value }))}
placeholder="Что именно не так в ответе и что нужно исправить."
rows={4}
disabled={assistantLiveCommentModal.saving}
/>
</label>
{assistantLiveCommentModal.error ? <p className="error-text">{assistantLiveCommentModal.error}</p> : null}
<div className="button-row">
<button type="button" onClick={() => void submitAssistantLiveCommentModal()} disabled={assistantLiveCommentModal.saving}>
{assistantLiveCommentModal.saving ? "Сохраняю..." : "Готово"}
</button>
<button
type="button"
className="tab"
onClick={() => closeAssistantLiveCommentModal()}
disabled={assistantLiveCommentModal.saving}
>
Отмена
</button>
</div>
</div>
</div>
) : null}
{commentModal.open ? ( {commentModal.open ? (
<div <div
className="autoruns-comment-modal-backdrop" className="autoruns-comment-modal-backdrop"

View File

@ -495,3 +495,32 @@ export interface AssistantMessageResultState {
debug: AssistantDebugState; debug: AssistantDebugState;
conversation: AssistantConversationItem[]; conversation: AssistantConversationItem[];
} }
export interface AssistantAnnotationRecord {
annotation_id: string;
session_id: string;
message_id: string;
message_index: number;
rating: number;
comment: string;
annotation_author: string | null;
created_at: string;
updated_at: string;
context: {
trace_id: string | null;
reply_type: string | null;
question_text: string | null;
answer_text: string | null;
};
technical_context: Record<string, unknown> | null;
}
export interface AssistantAnnotationsResponse {
ok: boolean;
generated_at: string;
filters_applied: {
session_id: string | null;
limit: number;
};
items: AssistantAnnotationRecord[];
}

View File

@ -363,6 +363,22 @@ textarea:focus {
textarea { textarea {
resize: vertical; resize: vertical;
min-height: 86px; min-height: 86px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
}
.assistant-input-textarea,
.autoruns-personality-prompt {
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable both-edges;
border-bottom-right-radius: 6px;
}
.assistant-input-textarea::-webkit-scrollbar-corner,
.autoruns-personality-prompt::-webkit-scrollbar-corner {
background: rgb(var(--rgb-surface-horizontal));
} }
button { button {
@ -494,6 +510,7 @@ button:disabled {
.assistant-msg-head { .assistant-msg-head {
display: flex; display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
margin-bottom: 0; margin-bottom: 0;
@ -501,6 +518,35 @@ button:disabled {
color: var(--text-muted); color: var(--text-muted);
} }
.assistant-msg-head-main {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.assistant-msg-head-actions {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.assistant-comment-btn {
cursor: pointer;
}
.assistant-comment-btn:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.assistant-msg.user .assistant-comment-btn {
color: rgba(var(--rgb-active-text), 0.92);
}
.assistant-msg-body { .assistant-msg-body {
white-space: pre-wrap; white-space: pre-wrap;
line-height: 1.35; line-height: 1.35;
@ -539,6 +585,66 @@ button:disabled {
margin-left: auto; margin-left: auto;
} }
.assistant-comments-frame .panel-body {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.assistant-comments-shell {
display: grid;
gap: 8px;
min-height: 0;
height: 100%;
}
.assistant-comments-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.assistant-comments-list {
display: grid;
gap: 8px;
overflow: auto;
min-height: 0;
padding-right: 2px;
}
.assistant-comment-item {
border: none;
border-radius: 10px;
background: rgb(var(--rgb-surface-horizontal));
padding: 8px;
display: grid;
gap: 6px;
}
.assistant-comment-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 0.75rem;
}
.assistant-comment-item p {
margin: 0;
white-space: pre-wrap;
font-size: 0.8rem;
}
.assistant-comment-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
color: var(--text-muted);
font-size: 0.74rem;
}
.app-root-autoruns .assistant-panel-frame .panel-header { .app-root-autoruns .assistant-panel-frame .panel-header {
position: sticky; position: sticky;
top: -12px; top: -12px;
@ -733,7 +839,8 @@ button:disabled {
width: var(--mode-column-width); width: var(--mode-column-width);
height: 100%; height: 100%;
min-height: 0; min-height: 0;
overflow: auto; overflow-y: auto;
overflow-x: hidden;
border: none; border: none;
border-radius: 14px; border-radius: 14px;
background: rgb(var(--rgb-surface-main)); background: rgb(var(--rgb-surface-main));
@ -1063,10 +1170,10 @@ button:disabled {
background: transparent; background: transparent;
color: rgb(var(--rgb-text-main)); color: rgb(var(--rgb-text-main));
border-radius: 0; border-radius: 0;
min-width: 20px; min-width: 16px;
min-height: 20px; min-height: 16px;
width: 20px; width: 16px;
height: 20px; height: 16px;
padding: 0; padding: 0;
line-height: 1; line-height: 1;
box-shadow: none; box-shadow: none;
@ -1074,24 +1181,37 @@ button:disabled {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer;
opacity: 0.92;
} }
.autoruns-comment-icon:hover { .autoruns-comment-icon:hover {
background: transparent; background: transparent;
color: rgb(var(--rgb-active)); color: rgb(var(--rgb-text-main));
opacity: 1;
box-shadow: none; box-shadow: none;
transform: none; transform: none;
} }
.autoruns-comment-icon.commented { .autoruns-comment-icon.commented {
color: rgb(var(--rgb-active-text)); color: rgb(var(--rgb-active));
background: transparent; background: transparent;
box-shadow: none; box-shadow: none;
} }
.autoruns-comment-icon:focus-visible {
outline: 1px solid rgba(var(--rgb-text-main), 0.7);
outline-offset: 1px;
}
.autoruns-comment-icon:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.comment-icon-svg { .comment-icon-svg {
width: 20px; width: 0.82rem;
height: 20px; height: 0.82rem;
stroke: currentColor; stroke: currentColor;
stroke-width: 1.75; stroke-width: 1.75;
stroke-linecap: round; stroke-linecap: round;
@ -1099,17 +1219,9 @@ button:disabled {
fill: none; fill: none;
} }
.comment-icon-svg .comment-icon-dot {
fill: currentColor;
}
.comment-icon-svg.commented { .comment-icon-svg.commented {
fill: rgb(var(--rgb-active));
stroke: rgb(var(--rgb-active)); stroke: rgb(var(--rgb-active));
} fill: none;
.autoruns-comment-icon.commented .comment-icon-dot {
fill: rgb(var(--rgb-active-text));
} }
.autoruns-resolve-toggle { .autoruns-resolve-toggle {
@ -1224,6 +1336,21 @@ button:disabled {
font-size: 0.8rem; font-size: 0.8rem;
} }
.autoruns-run-launch-btn {
background: rgb(var(--rgb-active));
color: rgb(var(--rgb-active-text));
}
.autoruns-run-launch-btn:hover {
background: rgb(var(--rgb-surface-focus));
color: rgb(var(--rgb-text-main));
}
.autoruns-run-launch-btn:disabled {
background: rgba(var(--rgb-active), 0.38);
color: rgba(var(--rgb-active-text), 0.88);
}
.autoruns-generated-questions { .autoruns-generated-questions {
border: none; border: none;
border-radius: 10px; border-radius: 10px;
@ -1249,31 +1376,58 @@ button:disabled {
} }
.autoruns-generated-question-item { .autoruns-generated-question-item {
display: flex; position: relative;
align-items: flex-start; display: block;
justify-content: space-between;
gap: 8px; gap: 8px;
border: none; border: none;
border-radius: 9px; border-radius: 9px;
background: rgb(var(--rgb-surface-focus)); background: rgb(var(--rgb-surface-focus));
padding: 6px 8px; padding: 7px 30px 7px 8px;
font-size: 0.78rem; font-size: 0.78rem;
} }
.autoruns-generated-question-item span { .autoruns-generated-question-item span {
display: block;
white-space: pre-wrap; white-space: pre-wrap;
} }
.autoruns-remove-question-btn { .autoruns-remove-question-btn {
position: absolute;
top: 6px;
right: 6px;
flex: 0 0 auto; flex: 0 0 auto;
border: none; border: none;
border-radius: 7px; border-radius: 0;
background: rgb(var(--rgb-surface-horizontal)); background: transparent;
color: var(--text-main); color: rgb(var(--rgb-text-main));
min-width: 24px; min-width: 16px;
height: 24px; width: 16px;
font-size: 0.75rem; height: 16px;
padding: 0;
font-size: 1rem;
font-weight: 700;
line-height: 1; line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
transform: rotate(45deg);
box-shadow: none;
transition: color 0.15s ease;
}
.autoruns-remove-question-btn:hover {
background: transparent;
color: rgb(var(--rgb-active-text));
box-shadow: none;
}
.autoruns-remove-question-btn:focus-visible {
outline: none;
}
.autoruns-personality-prompt {
resize: vertical;
min-height: 110px;
} }
.autoruns-comment-item { .autoruns-comment-item {