mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
feat(ai): UI импорта YAML + шаблон формата (frontend + docs)
Владелец грузит YAML на вкладке Знания/Инструкции → документы создаются/ обновляются и сразу публикуются. Парсинг на фронте (js-yaml) → JSON-пост через существующий api() (без multipart). - js-yaml + @types/js-yaml (новая зависимость). - docApi.ts: importDocs + тип ImportResult. - parseDocYaml.ts: парсер YAML со структурной валидацией (читаемые ошибки). - DocImportForm.tsx: UI file→preview→импорт→отчёт (created/updated/unchanged/ failed). По паттерну DocCreateForm (inline-форма). - Knowledge/InstructionsTab: кнопка «Импорт YAML». - styles.css: блоки import-preview/report. - parseDocYaml.test.ts: валидный YAML, global, невалидный синтаксис, пустые поля, невалидный inclusionMode. - SPEC-HUB-0012: формат-контракт + 2 примера (знания/инструкции) + промпт- набросок для ИИ-генерации.
This commit is contained in:
1 parent
8c3835ddf9
commit
cb799bcaf9
9 files changed
+320
No files matched your search
@@ -15,10 +15,12 @@
|
||||
"@edevs/shared": "0.1.0",
|
||||
"@edevs/ui": "0.1.0",
|
||||
"antd": "^5.26.0",
|
||||
"js-yaml": "^5.2.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { EmptyState } from "../../../shared/ui";
|
||||
import { DocCard } from "./DocCard";
|
||||
import { DocCreateForm } from "./DocCreateForm";
|
||||
import { DocImportForm } from "./DocImportForm";
|
||||
import { promptCategoryLabel, promptCategoryOptions, type PromptDoc } from "./model";
|
||||
|
||||
export function AiAgentInstructionsTab({ prompts, product, onChanged }: { prompts: PromptDoc[]; product: { code: string; name: string } | null; onChanged: () => void }) {
|
||||
@@ -21,6 +22,7 @@ export function AiAgentInstructionsTab({ prompts, product, onChanged }: { prompt
|
||||
/>
|
||||
))}
|
||||
<DocCreateForm kind="prompts" categories={promptCategoryOptions} product={product} onCreated={onChanged} />
|
||||
<DocImportForm kind="prompts" onImported={onChanged} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { EmptyState } from "../../../shared/ui";
|
||||
import { DocCard } from "./DocCard";
|
||||
import { DocCreateForm } from "./DocCreateForm";
|
||||
import { DocImportForm } from "./DocImportForm";
|
||||
import { knowledgeCategoryLabel, knowledgeCategoryOptions, type KnowledgeDoc } from "./model";
|
||||
|
||||
export function AiAgentKnowledgeTab({ knowledge, channelName, product, onChanged }: { knowledge: KnowledgeDoc[]; channelName: string; product: { code: string; name: string } | null; onChanged: () => void }) {
|
||||
@@ -30,6 +31,7 @@ export function AiAgentKnowledgeTab({ knowledge, channelName, product, onChanged
|
||||
/>
|
||||
))}
|
||||
<DocCreateForm kind="knowledge" categories={knowledgeCategoryOptions} product={product} withInclusion onCreated={onChanged} />
|
||||
<DocImportForm kind="knowledge" onImported={onChanged} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "../../../shared/ui-controls";
|
||||
import { importDocs, type DocKind, type ImportResult } from "./docApi";
|
||||
import { parseDocYaml } from "./parseDocYaml";
|
||||
|
||||
type DocImportFormProps = {
|
||||
kind: DocKind;
|
||||
onImported: () => void;
|
||||
};
|
||||
|
||||
export function DocImportForm({ kind, onImported }: DocImportFormProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [preview, setPreview] = useState<{ count: number; product: string | null } | null>(null);
|
||||
const [parsed, setParsed] = useState<ReturnType<typeof parseDocYaml> | null>(null);
|
||||
const [result, setResult] = useState<ImportResult | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function reset() {
|
||||
setPreview(null);
|
||||
setParsed(null);
|
||||
setResult(null);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function onFile(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = parseDocYaml(text);
|
||||
setParsed(data);
|
||||
setPreview({ count: data.documents.length, product: data.product });
|
||||
} catch (caught) {
|
||||
setParsed(null);
|
||||
setPreview(null);
|
||||
setError(caught instanceof Error ? caught.message : "Не удалось прочитать файл");
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!parsed) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const report = await importDocs(kind, parsed.product, parsed.documents);
|
||||
setResult(report);
|
||||
setParsed(null);
|
||||
setPreview(null);
|
||||
onImported();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Не удалось импортировать");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<div className="ai-doc-add">
|
||||
<Button variant="secondary" icon="download" onClick={() => setOpen(true)}>
|
||||
Импорт YAML
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="ai-card ai-doc-form">
|
||||
<div className="ai-doc-form-row">
|
||||
<label>
|
||||
<span>Файл YAML</span>
|
||||
<input type="file" accept=".yaml,.yml" onChange={(event) => void onFile(event)} />
|
||||
</label>
|
||||
</div>
|
||||
{preview && (
|
||||
<div className="ai-doc-import-preview">
|
||||
К импорту: <b>{preview.count}</b> документ(ов){preview.product ? `, продукт «${preview.product}»` : ", глобально"}.
|
||||
</div>
|
||||
)}
|
||||
{result && (
|
||||
<div className="ai-doc-import-report">
|
||||
<div className="ai-doc-import-summary">
|
||||
Создано: <b>{result.created}</b> · Обновлено: <b>{result.updated}</b> · Без изменений: <b>{result.unchanged}</b>
|
||||
</div>
|
||||
{result.failed.length > 0 && (
|
||||
<ul className="ai-doc-import-failed">
|
||||
{result.failed.map((item, index) => (
|
||||
<li key={index}>{item.code ? `«${item.code}»: ` : ""}{item.detail}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="ai-doc-error">{error}</div>}
|
||||
<div className="ai-doc-actions">
|
||||
<Button variant="secondary" disabled={busy} onClick={() => { reset(); setOpen(false); }}>Закрыть</Button>
|
||||
<Button variant="primary" icon="download" disabled={!parsed || busy} onClick={submit}>{busy ? "Импорт…" : "Импорт"}</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -59,6 +59,21 @@ export type CreateDocInput = {
|
||||
inclusionMode?: "MANDATORY" | "RETRIEVAL";
|
||||
};
|
||||
|
||||
export type ImportDocInput = {
|
||||
code: string;
|
||||
title: string;
|
||||
category: string;
|
||||
content: string;
|
||||
inclusionMode?: "MANDATORY" | "RETRIEVAL";
|
||||
};
|
||||
|
||||
export type ImportResult = {
|
||||
created: number;
|
||||
updated: number;
|
||||
unchanged: number;
|
||||
failed: Array<{ code: string; detail: string }>;
|
||||
};
|
||||
|
||||
function slugCode(title: string): string {
|
||||
// SlugField хранит ASCII; кириллица отбрасывается → fallback на "doc".
|
||||
const slug = title
|
||||
@@ -75,3 +90,10 @@ export async function createDoc(kind: DocKind, input: CreateDocInput): Promise<v
|
||||
body: JSON.stringify({ ...input, code: slugCode(input.title) }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function importDocs(kind: DocKind, product: string | null, documents: ImportDocInput[]): Promise<ImportResult> {
|
||||
return api<ImportResult>(`${base(kind)}/import/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...(product ? { product } : {}), documents }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseDocYaml } from "./parseDocYaml";
|
||||
|
||||
describe("parseDocYaml", () => {
|
||||
it("parses a valid YAML into product + documents", () => {
|
||||
const text = `
|
||||
product: foxray
|
||||
documents:
|
||||
- code: foxray-overview
|
||||
title: Обзор продукта
|
||||
category: OVERVIEW
|
||||
inclusionMode: MANDATORY
|
||||
content: |
|
||||
Многострочный
|
||||
текст
|
||||
- code: foxray-faq
|
||||
title: FAQ
|
||||
category: FAQ
|
||||
content: Вопрос?
|
||||
`;
|
||||
const result = parseDocYaml(text);
|
||||
|
||||
expect(result.product).toBe("foxray");
|
||||
expect(result.documents).toHaveLength(2);
|
||||
expect(result.documents[0]).toEqual({ code: "foxray-overview", title: "Обзор продукта", category: "OVERVIEW", content: "Многострочный\nтекст\n", inclusionMode: "MANDATORY" });
|
||||
expect(result.documents[1].inclusionMode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats absent product as global (null)", () => {
|
||||
const text = `
|
||||
documents:
|
||||
- code: global-overview
|
||||
title: Обзор
|
||||
category: OVERVIEW
|
||||
content: текст
|
||||
`;
|
||||
expect(parseDocYaml(text).product).toBeNull();
|
||||
});
|
||||
|
||||
it("throws on invalid YAML syntax", () => {
|
||||
expect(() => parseDocYaml("product: foxray\n documents: [unclosed")).toThrow(/YAML/);
|
||||
});
|
||||
|
||||
it("throws when documents is missing or empty", () => {
|
||||
expect(() => parseDocYaml("product: foxray")).toThrow(/documents/);
|
||||
expect(() => parseDocYaml("documents: []")).toThrow(/documents/);
|
||||
});
|
||||
|
||||
it("throws when a required field is empty", () => {
|
||||
const text = `
|
||||
documents:
|
||||
- code: ""
|
||||
title: Обзор
|
||||
category: OVERVIEW
|
||||
content: текст
|
||||
`;
|
||||
expect(() => parseDocYaml(text)).toThrow(/code/);
|
||||
});
|
||||
|
||||
it("throws on an invalid inclusionMode", () => {
|
||||
const text = `
|
||||
documents:
|
||||
- code: foxray-overview
|
||||
title: Обзор
|
||||
category: OVERVIEW
|
||||
inclusionMode: MAYBE
|
||||
content: текст
|
||||
`;
|
||||
expect(() => parseDocYaml(text)).toThrow(/inclusionMode/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { load as parseYaml } from "js-yaml";
|
||||
|
||||
import type { ImportDocInput } from "./docApi";
|
||||
|
||||
export type ParsedDocYaml = {
|
||||
product: string | null;
|
||||
documents: ImportDocInput[];
|
||||
};
|
||||
|
||||
const REQUIRED_FIELDS = ["code", "title", "category", "content"] as const;
|
||||
|
||||
// Парсит YAML импорта знаний/инструкций и структурно валидирует.
|
||||
// Бросает Error с человекочитаемым сообщением при невалидном YAML или структуре.
|
||||
export function parseDocYaml(text: string): ParsedDocYaml {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = parseYaml(text);
|
||||
} catch (error) {
|
||||
throw new Error(error instanceof Error ? `Невалидный YAML: ${error.message}` : "Невалидный YAML");
|
||||
}
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new Error("Ожидается объект верхнего уровня с ключом documents");
|
||||
}
|
||||
const root = data as { product?: unknown; documents?: unknown };
|
||||
const product = root.product === undefined || root.product === null ? null : String(root.product).trim();
|
||||
if (product && !/^[a-z0-9-]+$/i.test(product)) {
|
||||
throw new Error(`product должен быть ASCII-slug, получено: «${product}»`);
|
||||
}
|
||||
if (!Array.isArray(root.documents) || root.documents.length === 0) {
|
||||
throw new Error("documents должен быть непустым списком");
|
||||
}
|
||||
const documents: ImportDocInput[] = [];
|
||||
root.documents.forEach((item, index) => {
|
||||
if (!item || typeof item !== "object") {
|
||||
throw new Error(`Документ #${index + 1}: должен быть объектом`);
|
||||
}
|
||||
const record = item as Record<string, unknown>;
|
||||
for (const field of REQUIRED_FIELDS) {
|
||||
const value = record[field];
|
||||
if (value === undefined || value === null || String(value).trim() === "") {
|
||||
throw new Error(`Документ #${index + 1}: обязательное поле «${field}» пустое`);
|
||||
}
|
||||
}
|
||||
const doc: ImportDocInput = {
|
||||
code: String(record.code).trim(),
|
||||
title: String(record.title).trim(),
|
||||
category: String(record.category).trim(),
|
||||
content: String(record.content),
|
||||
};
|
||||
if (record.inclusionMode !== undefined && record.inclusionMode !== null) {
|
||||
const inclusion = String(record.inclusionMode).trim();
|
||||
if (inclusion !== "MANDATORY" && inclusion !== "RETRIEVAL") {
|
||||
throw new Error(`Документ #${index + 1}: inclusionMode должен быть MANDATORY или RETRIEVAL`);
|
||||
}
|
||||
doc.inclusionMode = inclusion;
|
||||
}
|
||||
documents.push(doc);
|
||||
});
|
||||
return { product: product || null, documents };
|
||||
}
|
||||
@@ -611,3 +611,21 @@
|
||||
border-color: #cf1322;
|
||||
}
|
||||
|
||||
/* --- Doc import (YAML) --- */
|
||||
.ai-doc-import-preview,
|
||||
.ai-doc-import-summary {
|
||||
font-size: 13px;
|
||||
color: #262626;
|
||||
}
|
||||
.ai-doc-import-report {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.ai-doc-import-failed {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: #cf1322;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
Generated
+37
@@ -48,10 +48,12 @@
|
||||
"@edevs/shared": "0.1.0",
|
||||
"@edevs/ui": "0.1.0",
|
||||
"antd": "^5.26.0",
|
||||
"js-yaml": "^5.2.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0"
|
||||
}
|
||||
@@ -1596,6 +1598,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/js-yaml": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
|
||||
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||
@@ -1858,6 +1867,12 @@
|
||||
"react-dom": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -2192,6 +2207,28 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.1.tgz",
|
||||
"integrity": "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
|
||||
Reference in new issue
Block a user