diff --git a/backend/apps/knowledge/__init__.py b/backend/apps/knowledge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/knowledge/api.py b/backend/apps/knowledge/api.py new file mode 100644 index 0000000..81250e9 --- /dev/null +++ b/backend/apps/knowledge/api.py @@ -0,0 +1,83 @@ +from ninja import Router + +from apps.accounts.api import require_permission +from apps.knowledge.models import KnowledgeBase, KnowledgeItem +from apps.knowledge.schemas import ( + KnowledgeBaseIn, + KnowledgeBaseOut, + KnowledgeDocumentIn, + KnowledgeDocumentOut, + KnowledgeItemIn, + KnowledgeItemOut, +) +from apps.knowledge.services import ingest_document_text, rebuild_item_chunks + +router = Router(tags=["knowledge"]) + + +@router.post("/bases", response=KnowledgeBaseOut) +def create_knowledge_base(request, payload: KnowledgeBaseIn): + require_permission(request, "knowledge.manage") + return KnowledgeBase.objects.create( + name=payload.name, + description=payload.description, + scope=payload.scope, + enabled=payload.enabled, + created_by=request.user if request.user.is_authenticated else None, + ) + + +@router.get("/bases", response=list[KnowledgeBaseOut]) +def list_knowledge_bases(request, enabled: bool | None = None): + require_permission(request, "knowledge.view") + queryset = KnowledgeBase.objects.all() + if enabled is not None: + queryset = queryset.filter(enabled=enabled) + return queryset[:100] + + +@router.post("/items", response=KnowledgeItemOut) +def create_knowledge_item(request, payload: KnowledgeItemIn): + require_permission(request, "knowledge.manage") + item = KnowledgeItem.objects.create( + knowledge_base_id=payload.knowledge_base_id, + title=payload.title, + problem=payload.problem, + applicable_scope=payload.applicable_scope, + error_code=payload.error_code, + root_cause=payload.root_cause, + troubleshooting_steps=payload.troubleshooting_steps, + solution=payload.solution, + risk_note=payload.risk_note, + status=payload.status, + created_by=request.user if request.user.is_authenticated else None, + ) + rebuild_item_chunks(item) + return item + + +@router.get("/items", response=list[KnowledgeItemOut]) +def list_knowledge_items( + request, + knowledge_base_id: int | None = None, + status: str | None = None, +): + require_permission(request, "knowledge.view") + queryset = KnowledgeItem.objects.all() + if knowledge_base_id: + queryset = queryset.filter(knowledge_base_id=knowledge_base_id) + if status: + queryset = queryset.filter(status=status) + return queryset[:100] + + +@router.post("/documents", response=KnowledgeDocumentOut) +def create_knowledge_document(request, payload: KnowledgeDocumentIn): + require_permission(request, "knowledge.manage") + return ingest_document_text( + knowledge_base_id=payload.knowledge_base_id, + file_name=payload.file_name, + file_type=payload.file_type, + content=payload.content, + operator=request.user if request.user.is_authenticated else None, + ) diff --git a/backend/apps/knowledge/apps.py b/backend/apps/knowledge/apps.py new file mode 100644 index 0000000..6f37e66 --- /dev/null +++ b/backend/apps/knowledge/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class KnowledgeConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.knowledge" diff --git a/backend/apps/knowledge/migrations/0001_initial.py b/backend/apps/knowledge/migrations/0001_initial.py new file mode 100644 index 0000000..47d6319 --- /dev/null +++ b/backend/apps/knowledge/migrations/0001_initial.py @@ -0,0 +1,97 @@ +# ruff: noqa: E501 +# Generated by Django 5.2.15 on 2026-07-03 09:48 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='KnowledgeBase', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=160)), + ('description', models.TextField(blank=True)), + ('scope', models.CharField(blank=True, max_length=120)), + ('enabled', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-updated_at'], + }, + ), + migrations.CreateModel( + name='KnowledgeDocument', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file_name', models.CharField(max_length=255)), + ('file_type', models.CharField(blank=True, max_length=80)), + ('storage_path', models.CharField(blank=True, max_length=500)), + ('content_text', models.TextField(blank=True)), + ('parse_status', models.CharField(default='pending', max_length=40)), + ('parse_error', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('knowledge_base', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='knowledge.knowledgebase')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='KnowledgeItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('problem', models.TextField()), + ('applicable_scope', models.TextField(blank=True)), + ('error_code', models.CharField(blank=True, max_length=120)), + ('root_cause', models.TextField(blank=True)), + ('troubleshooting_steps', models.TextField(blank=True)), + ('solution', models.TextField()), + ('risk_note', models.TextField(blank=True)), + ('status', models.CharField(default='draft', max_length=40)), + ('source_type', models.CharField(default='manual', max_length=40)), + ('source_ticket_id', models.BigIntegerField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_knowledge_items', to=settings.AUTH_USER_MODEL)), + ('knowledge_base', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='knowledge.knowledgebase')), + ('reviewed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_knowledge_items', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-updated_at'], + }, + ), + migrations.CreateModel( + name='KnowledgeChunk', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('chunk_index', models.PositiveIntegerField(default=0)), + ('content', models.TextField()), + ('embedding', models.JSONField(blank=True, default=list)), + ('embedding_model', models.CharField(default='local-hash-v1', max_length=120)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('knowledge_base', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chunks', to='knowledge.knowledgebase')), + ('document', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='chunks', to='knowledge.knowledgedocument')), + ('item', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='chunks', to='knowledge.knowledgeitem')), + ], + options={ + 'ordering': ['knowledge_base_id', 'document_id', 'item_id', 'chunk_index'], + 'indexes': [models.Index(fields=['knowledge_base', 'chunk_index'], name='knowledge_k_knowled_949c7b_idx')], + }, + ), + ] diff --git a/backend/apps/knowledge/migrations/__init__.py b/backend/apps/knowledge/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/knowledge/models.py b/backend/apps/knowledge/models.py new file mode 100644 index 0000000..bae47f6 --- /dev/null +++ b/backend/apps/knowledge/models.py @@ -0,0 +1,120 @@ +from django.conf import settings +from django.db import models + + +class KnowledgeBase(models.Model): + name = models.CharField(max_length=160) + description = models.TextField(blank=True) + scope = models.CharField(max_length=120, blank=True) + enabled = models.BooleanField(default=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-updated_at"] + + def __str__(self) -> str: + return self.name + + +class KnowledgeDocument(models.Model): + knowledge_base = models.ForeignKey( + KnowledgeBase, + on_delete=models.CASCADE, + related_name="documents", + ) + file_name = models.CharField(max_length=255) + file_type = models.CharField(max_length=80, blank=True) + storage_path = models.CharField(max_length=500, blank=True) + content_text = models.TextField(blank=True) + parse_status = models.CharField(max_length=40, default="pending") + parse_error = models.TextField(blank=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-created_at"] + + +class KnowledgeItem(models.Model): + knowledge_base = models.ForeignKey( + KnowledgeBase, + on_delete=models.CASCADE, + related_name="items", + ) + title = models.CharField(max_length=200) + problem = models.TextField() + applicable_scope = models.TextField(blank=True) + error_code = models.CharField(max_length=120, blank=True) + root_cause = models.TextField(blank=True) + troubleshooting_steps = models.TextField(blank=True) + solution = models.TextField() + risk_note = models.TextField(blank=True) + status = models.CharField(max_length=40, default="draft") + source_type = models.CharField(max_length=40, default="manual") + source_ticket_id = models.BigIntegerField(null=True, blank=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="created_knowledge_items", + ) + reviewed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="reviewed_knowledge_items", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-updated_at"] + + +class KnowledgeChunk(models.Model): + knowledge_base = models.ForeignKey( + KnowledgeBase, + on_delete=models.CASCADE, + related_name="chunks", + ) + document = models.ForeignKey( + KnowledgeDocument, + null=True, + blank=True, + on_delete=models.CASCADE, + related_name="chunks", + ) + item = models.ForeignKey( + KnowledgeItem, + null=True, + blank=True, + on_delete=models.CASCADE, + related_name="chunks", + ) + chunk_index = models.PositiveIntegerField(default=0) + content = models.TextField() + embedding = models.JSONField(default=list, blank=True) + embedding_model = models.CharField(max_length=120, default="local-hash-v1") + metadata = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["knowledge_base_id", "document_id", "item_id", "chunk_index"] + indexes = [ + models.Index(fields=["knowledge_base", "chunk_index"]), + ] diff --git a/backend/apps/knowledge/schemas.py b/backend/apps/knowledge/schemas.py new file mode 100644 index 0000000..7403818 --- /dev/null +++ b/backend/apps/knowledge/schemas.py @@ -0,0 +1,67 @@ +from datetime import datetime + +from ninja import Schema + + +class KnowledgeBaseIn(Schema): + name: str + description: str = "" + scope: str = "" + enabled: bool = True + + +class KnowledgeBaseOut(Schema): + id: int + name: str + description: str + scope: str + enabled: bool + created_at: datetime + updated_at: datetime + + +class KnowledgeDocumentIn(Schema): + knowledge_base_id: int + file_name: str + file_type: str = "" + content: str + + +class KnowledgeDocumentOut(Schema): + id: int + knowledge_base_id: int + file_name: str + file_type: str + parse_status: str + parse_error: str + created_at: datetime + + +class KnowledgeItemIn(Schema): + knowledge_base_id: int + title: str + problem: str + applicable_scope: str = "" + error_code: str = "" + root_cause: str = "" + troubleshooting_steps: str = "" + solution: str + risk_note: str = "" + status: str = "draft" + + +class KnowledgeItemOut(Schema): + id: int + knowledge_base_id: int + title: str + problem: str + applicable_scope: str + error_code: str + root_cause: str + troubleshooting_steps: str + solution: str + risk_note: str + status: str + source_type: str + created_at: datetime + updated_at: datetime diff --git a/backend/apps/knowledge/services.py b/backend/apps/knowledge/services.py new file mode 100644 index 0000000..2a60d49 --- /dev/null +++ b/backend/apps/knowledge/services.py @@ -0,0 +1,87 @@ +import hashlib +import math + +from apps.knowledge.models import KnowledgeChunk, KnowledgeDocument, KnowledgeItem + +DEFAULT_CHUNK_SIZE = 600 + + +def split_text(content: str, chunk_size: int = DEFAULT_CHUNK_SIZE) -> list[str]: + normalized = "\n".join(line.strip() for line in content.splitlines() if line.strip()) + if not normalized: + return [] + return [ + normalized[index : index + chunk_size] + for index in range(0, len(normalized), chunk_size) + ] + + +def embed_text(content: str, dimensions: int = 16) -> list[float]: + digest = hashlib.sha256(content.encode("utf-8")).digest() + values = [digest[index] / 255 for index in range(dimensions)] + length = math.sqrt(sum(value * value for value in values)) or 1 + return [round(value / length, 6) for value in values] + + +def rebuild_item_chunks(item: KnowledgeItem) -> list[KnowledgeChunk]: + item.chunks.all().delete() + content = "\n".join( + [ + item.title, + item.problem, + item.applicable_scope, + item.error_code, + item.root_cause, + item.troubleshooting_steps, + item.solution, + item.risk_note, + ] + ) + chunks = [] + for index, chunk_text in enumerate(split_text(content)): + chunks.append( + KnowledgeChunk.objects.create( + knowledge_base=item.knowledge_base, + item=item, + chunk_index=index, + content=chunk_text, + embedding=embed_text(chunk_text), + metadata={ + "source_type": "knowledge_item", + "title": item.title, + "item_id": item.id, + }, + ) + ) + return chunks + + +def ingest_document_text( + knowledge_base_id: int, + file_name: str, + file_type: str, + content: str, + operator=None, +) -> KnowledgeDocument: + document = KnowledgeDocument.objects.create( + knowledge_base_id=knowledge_base_id, + file_name=file_name, + file_type=file_type, + content_text=content, + parse_status="success", + created_by=operator, + ) + for index, chunk_text in enumerate(split_text(content)): + KnowledgeChunk.objects.create( + knowledge_base=document.knowledge_base, + document=document, + chunk_index=index, + content=chunk_text, + embedding=embed_text(chunk_text), + metadata={ + "source_type": "document", + "title": document.file_name, + "document_id": document.id, + }, + ) + return document diff --git a/backend/apps/knowledge/tests/__init__.py b/backend/apps/knowledge/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/knowledge/tests/test_knowledge_api.py b/backend/apps/knowledge/tests/test_knowledge_api.py new file mode 100644 index 0000000..e6dddd6 --- /dev/null +++ b/backend/apps/knowledge/tests/test_knowledge_api.py @@ -0,0 +1,69 @@ +from apps.knowledge.models import KnowledgeBase, KnowledgeChunk, KnowledgeDocument, KnowledgeItem + + +def test_create_and_list_knowledge_base(admin_client, db): + response = admin_client.post( + "/api/knowledge/bases", + data={ + "name": "税务采集知识库", + "description": "税局采集、日志和常见问题", + "scope": "数据采集", + }, + content_type="application/json", + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["name"] == "税务采集知识库" + assert payload["enabled"] is True + + response = admin_client.get("/api/knowledge/bases") + + assert response.status_code == 200 + assert response.json()[0]["name"] == "税务采集知识库" + + +def test_create_knowledge_item_builds_chunks(admin_client, db): + base = KnowledgeBase.objects.create(name="税务采集知识库", scope="数据采集") + + response = admin_client.post( + "/api/knowledge/items", + data={ + "knowledge_base_id": base.id, + "title": "税局采集进度查询", + "problem": "客户询问采集进度长时间不更新", + "applicable_scope": "生产环境税局采集", + "error_code": "", + "root_cause": "税局服务响应不稳定", + "troubleshooting_steps": "查询 Python 日志并确认采集任务状态", + "solution": "告知客户税局不稳定,任务仍在采集中,稍后复查。", + "risk_note": "不要重复触发采集任务。", + "status": "published", + }, + content_type="application/json", + ) + + assert response.status_code == 200 + item = KnowledgeItem.objects.get(id=response.json()["id"]) + assert item.status == "published" + assert KnowledgeChunk.objects.filter(item=item).count() >= 1 + + +def test_ingest_document_text_creates_document_and_chunks(admin_client, db): + base = KnowledgeBase.objects.create(name="日志知识库", scope="技术支持") + + response = admin_client.post( + "/api/knowledge/documents", + data={ + "knowledge_base_id": base.id, + "file_name": "tax-log.md", + "file_type": "markdown", + "content": "税局接口不稳定时,采集任务会停留在处理中。需要查询 Python 日志确认。", + }, + content_type="application/json", + ) + + assert response.status_code == 200 + document = KnowledgeDocument.objects.get(id=response.json()["id"]) + assert document.parse_status == "success" + assert KnowledgeChunk.objects.filter(document=document).count() >= 1 diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py index 4162d86..32dcd3c 100644 --- a/backend/config/settings/base.py +++ b/backend/config/settings/base.py @@ -20,6 +20,7 @@ INSTALLED_APPS = [ "apps.system", "apps.tickets", "apps.solutions", + "apps.knowledge", ] MIDDLEWARE = [ diff --git a/backend/config/urls.py b/backend/config/urls.py index 0526748..47208cd 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -4,6 +4,7 @@ from ninja import NinjaAPI from apps.accounts.api import router as accounts_router from apps.audit.api import router as audit_router +from apps.knowledge.api import router as knowledge_router from apps.solutions.api import router as solutions_router from apps.system.api import router as system_router from apps.tickets.api import router as tickets_router @@ -14,6 +15,7 @@ api.add_router("/system", system_router) api.add_router("/audit", audit_router) api.add_router("/tickets", tickets_router) api.add_router("/solutions", solutions_router) +api.add_router("/knowledge", knowledge_router) urlpatterns = [ path("admin/", admin.site.urls),