From ee8367e2050c7c19675e5c6a789d9b185dfec5da Mon Sep 17 00:00:00 2001 From: Andrey Date: Mon, 14 Sep 2026 22:36:35 +0300 Subject: [PATCH] =?UTF-8?q?:bug:=20fix:=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BA=D0=B0=D1=82=D0=B5=D0=B3=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=B8=20=D0=BF=D1=80=D0=B8=20=D0=B8=D0=BC=D0=BF?= =?UTF-8?q?=D0=BE=D1=80=D1=82=D0=B5=20=D0=B8=20=D0=B2=D0=BE=D1=81=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D1=8C=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BC=D0=B5?= =?UTF-8?q?=D1=82=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Создавать недостающие уровни categoryPath в транзакции документа и повторно использовать существующие категории. Разрешить импорт новых путей в предпросмотре, обновить переводы и baseline. Исправить конфликт ширины кнопки создания метки со стилями меню. Выделить метки, заметку и план импорта в отдельные компоненты. Проверено: 13 backend-тестов, 7 frontend-тестов, сборка и браузерный предпросмотр файла из 66 документов. --- apps/backend/chatballs/ai/knowledge_import.py | 13 +- .../chatballs/ai/test_knowledge_import_v2.py | 57 ++++++-- .../ai/knowledge/KnowledgeImportPage.tsx | 112 +--------------- .../ai/knowledge/KnowledgeImportPreview.tsx | 40 ++++++ .../ai/knowledge/knowledgeImportPlan.test.ts | 50 +++++++ .../ai/knowledge/knowledgeImportPlan.ts | 62 +++++++++ .../ai/knowledge/parseKnowledgeYaml.ts | 3 +- .../features/conversations/DialogControls.tsx | 124 +----------------- .../features/conversations/DialogLabels.tsx | 110 ++++++++++++++++ .../conversations/DialogNoteSection.tsx | 34 +++++ .../features/conversations/dialogLabels.css | 8 ++ apps/internal-ui/src/i18n/en.ts | 5 +- apps/internal-ui/src/i18n/ru.ts | 5 +- .../baseline/Chatballs Baseline v2/README.md | 2 +- .../База знаний Baseline.dc.html | 9 +- 15 files changed, 375 insertions(+), 259 deletions(-) create mode 100644 apps/internal-ui/src/features/ai/knowledge/KnowledgeImportPreview.tsx create mode 100644 apps/internal-ui/src/features/ai/knowledge/knowledgeImportPlan.test.ts create mode 100644 apps/internal-ui/src/features/ai/knowledge/knowledgeImportPlan.ts create mode 100644 apps/internal-ui/src/features/conversations/DialogLabels.tsx create mode 100644 apps/internal-ui/src/features/conversations/DialogNoteSection.tsx create mode 100644 apps/internal-ui/src/features/conversations/dialogLabels.css diff --git a/apps/backend/chatballs/ai/knowledge_import.py b/apps/backend/chatballs/ai/knowledge_import.py index d6ab6de..b341b53 100644 --- a/apps/backend/chatballs/ai/knowledge_import.py +++ b/apps/backend/chatballs/ai/knowledge_import.py @@ -66,14 +66,11 @@ def _category_for_path(*, context: TenantContext, raw_path: object) -> Knowledge for raw_name in raw_path: if not isinstance(raw_name, str) or not raw_name.strip(): raise ValidationError({"categoryPath": t("ai.category_names_strings")}) - try: - category = KnowledgeCategory.objects.get( - organization_id=context.organization_id, - parent_id=parent_id, - name=raw_name.strip(), - ) - except KnowledgeCategory.DoesNotExist as error: - raise ValidationError({"categoryPath": t("ai.category_path_not_found")}) from error + category, _ = KnowledgeCategory.objects.get_or_create( + organization_id=context.organization_id, + parent_id=parent_id, + name=raw_name.strip(), + ) parent_id = category.id assert category is not None return category diff --git a/apps/backend/chatballs/ai/test_knowledge_import_v2.py b/apps/backend/chatballs/ai/test_knowledge_import_v2.py index 2994959..1c15510 100644 --- a/apps/backend/chatballs/ai/test_knowledge_import_v2.py +++ b/apps/backend/chatballs/ai/test_knowledge_import_v2.py @@ -65,7 +65,7 @@ class KnowledgeMetadataImportTests(TestCase): knowledge = Knowledge.objects.get(title="Acme support") self.assertEqual(knowledge.category_id, self.app.id) - def test_unknown_category_path_fails_per_document(self) -> None: + def test_missing_category_is_created_under_existing_parent(self) -> None: response = self._import( [ { @@ -78,15 +78,52 @@ class KnowledgeMetadataImportTests(TestCase): ) payload = response.json() - self.assertEqual(payload["created"], 1) - self.assertEqual(len(payload["failed"]), 1) - self.assertFalse(Knowledge.objects.filter(title="Unknown path").exists()) - self.assertFalse( - KnowledgeCategory.objects.filter( - organization=self.organization, - name="Missing", - ).exists() + self.assertEqual(payload["created"], 2) + self.assertEqual(payload["failed"], []) + category = KnowledgeCategory.objects.get( + organization=self.organization, parent=self.products, name="Missing" ) + self.assertEqual(Knowledge.objects.get(title="Unknown path").category_id, category.id) + + def test_new_tree_is_shared_and_repeat_import_does_not_duplicate_categories(self) -> None: + documents = [ + {"title": title, "content": "Text", "categoryPath": [" New root ", "Child", "Leaf"]} + for title in ["First", "Second"] + ] + before = KnowledgeCategory.objects.count() + self.assertEqual(self._import(documents).json()["created"], 2) + self.assertEqual(self._import(documents).json()["unchanged"], 2) + self.assertEqual(KnowledgeCategory.objects.count(), before + 3) + first = Knowledge.objects.get(title="First") + second = Knowledge.objects.get(title="Second") + self.assertEqual(first.category_id, second.category_id) + self.assertEqual(first.category.parent.parent.name, "New root") + + def test_failed_document_rolls_back_new_categories_and_other_documents_import(self) -> None: + response = self._import([ + {"title": "Invalid", "content": "Text", "description": [], + "categoryPath": ["Rollback root", "Child"]}, + {"title": "Valid sibling", "content": "Text", "categoryPath": ["Kept root"]}, + ]) + self.assertEqual(response.json()["created"], 1) + self.assertEqual(len(response.json()["failed"]), 1) + self.assertFalse(KnowledgeCategory.objects.filter(name="Rollback root").exists()) + self.assertFalse(Knowledge.objects.filter(title="Invalid").exists()) + + def test_invalid_path_rolls_back_preceding_levels(self) -> None: + for path in [["Invalid root", " "], ["Invalid root", 42], []]: + with self.subTest(path=path): + response = self._import([{"title": "Invalid path", "content": "Text", "categoryPath": path}]) + self.assertEqual(len(response.json()["failed"]), 1) + self.assertFalse(KnowledgeCategory.objects.filter(name="Invalid root").exists()) + + def test_existing_document_moves_to_new_category(self) -> None: + self._import([{"title": "Moving", "content": "Text"}]) + response = self._import([ + {"title": "Moving", "content": "Text", "categoryPath": ["New destination"]} + ]) + self.assertEqual(response.json()["updated"], 1) + self.assertEqual(Knowledge.objects.get(title="Moving").category.name, "New destination") def test_omitted_metadata_preserves_category_and_agent_links(self) -> None: self._import( @@ -157,6 +194,7 @@ class KnowledgeImportPolicyTests(KnowledgePolicyTestBase): { "title": self.support_only.title, "content": "Attempted overwrite", + "categoryPath": ["Forbidden category"], } ] } @@ -167,3 +205,4 @@ class KnowledgeImportPolicyTests(KnowledgePolicyTestBase): self.assertEqual(response.status_code, 403) self.support_only.refresh_from_db() self.assertEqual(self.support_only.content, "") + self.assertFalse(KnowledgeCategory.objects.filter(name="Forbidden category").exists()) diff --git a/apps/internal-ui/src/features/ai/knowledge/KnowledgeImportPage.tsx b/apps/internal-ui/src/features/ai/knowledge/KnowledgeImportPage.tsx index d6c929b..72939ee 100644 --- a/apps/internal-ui/src/features/ai/knowledge/KnowledgeImportPage.tsx +++ b/apps/internal-ui/src/features/ai/knowledge/KnowledgeImportPage.tsx @@ -16,79 +16,8 @@ import { } from "./model"; import { t, tn } from "../../../i18n"; import { readableSize } from "../../../shared/utils"; - -// Импорт YAML (дизайн-базлайн v2, кадр KB8): файл разбирается до применения — -// видно, что создастся, что обновится и где ошибка пути. Импорт не создаёт -// категории неявно, поэтому несуществующий categoryPath отклоняется отдельно. - -type ImportAction = "create" | "update" | "error"; - -type ImportRow = { - document: ParsedKnowledgeYaml["documents"][number]; - action: ImportAction; - path: string; - note: string; -}; - -const ACTION_LABEL: Record = { - create: t("ai.create"), - update: t("ai.update"), - error: t("ai.error"), -}; - -/** Разрешает путь категории в дерево организации: путь ищется по уровням. */ -function resolvePath(categories: KnowledgeCategory[], path: string[]): { ok: boolean; missing: string } { - let parentId: number | null = null; - for (const name of path) { - const found: KnowledgeCategory | undefined = categories.find( - (category) => category.parentId === parentId && category.name === name, - ); - if (!found) return { ok: false, missing: name }; - parentId = found.id; - } - return { ok: true, missing: "" }; -} - -function planRows( - parsed: ParsedKnowledgeYaml, - categories: KnowledgeCategory[], - items: KnowledgeItem[], -): ImportRow[] { - const byTitle = new Map(items.map((item) => [item.title, item])); - return parsed.documents.map((document) => { - const existing = byTitle.get(document.title) ?? null; - if (document.categoryPath) { - const { ok, missing } = resolvePath(categories, document.categoryPath); - if (!ok) { - return { - document, - action: "error", - path: document.categoryPath.join(" / "), - note: t("ai.missing_category_path", { name: missing }), - }; - } - } - const path = document.categoryPath ? document.categoryPath.join(" / ") : t("ai.not_given"); - if (!existing) { - return { - document, - action: "create", - path, - note: document.categoryPath ? "" : t("ai.will_land_no_category"), - }; - } - const samePlace = document.categoryPath - && document.categoryPath[document.categoryPath.length - 1] === existing.category.name; - return { - document, - action: "update", - path, - note: samePlace - ? t("ai.category_file_matches_current_one") - : t("ai.title_matched_text_will_replaced"), - }; - }); -} +import { planRows } from "./knowledgeImportPlan"; +import { KnowledgeImportPreview } from "./KnowledgeImportPreview"; export function KnowledgeImportPage({ canManage, @@ -218,44 +147,11 @@ export function KnowledgeImportPage({ )} - {parsed && ( -
-
- {t("ai.what_will_happen")} - - {counts.create > 0 && {t("ai.count_create", { count: counts.create })}} - {counts.update > 0 && {t("ai.count_update", { count: counts.update })}} - {counts.error > 0 && {t("ai.count_error", { count: counts.error })}} - -
- - - - - - - - - - - {rows.map((row, index) => ( - - - - - - - ))} - -
{t("ai.document")}CATEGORYPATH{t("ai.action")}{t("ai.note")}
{row.document.title}{row.path}{ACTION_LABEL[row.action]}{row.note}
-
- )} + {parsed && } {parsed && (
- {t("ai.import_never_creates_categories_implicitly")}categoryPath{" "} - {t("ai.import_path_tail")} - + {t("ai.import_category_policy")}
- -
- ({ - key: label.id, - label: , - })), - { - key: "new", - label: ( -
event.stopPropagation()}> - setNewLabel(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - void addLabel(); - } - }} - /> - -
- ), - }, - ], - }} - > - -
- {detail.labels.map((label) => ( - - - {label.name} - - - ))} -
+
{detail.waitingSince ? t("conversations.in_queue_since") : t("conversations.started")} @@ -270,36 +186,6 @@ export function DialogControls({ ); } -function NoteSection({ detail, busy, onSave }: { detail: ApiConversation; busy: boolean; onSave: (note: string) => Promise }) { - const [editing, setEditing] = useState(false); - const [draft, setDraft] = useState(detail.note); - - useEffect(() => { - setEditing(false); - setDraft(detail.note); - }, [detail.id, detail.note]); - - return ( -
-
-

{t("conversations.note")}

- -
- {editing ? ( -
-