feat: add knowledge base core api

This commit is contained in:
hailin
2026-07-03 17:49:44 +08:00
parent 6b427f26f8
commit 374c36ac65
12 changed files with 532 additions and 0 deletions

View File

View File

@@ -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,
)

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class KnowledgeConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.knowledge"

View File

@@ -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')],
},
),
]

View File

@@ -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"]),
]

View File

@@ -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

View File

@@ -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

View File

View File

@@ -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

View File

@@ -20,6 +20,7 @@ INSTALLED_APPS = [
"apps.system", "apps.system",
"apps.tickets", "apps.tickets",
"apps.solutions", "apps.solutions",
"apps.knowledge",
] ]
MIDDLEWARE = [ MIDDLEWARE = [

View File

@@ -4,6 +4,7 @@ from ninja import NinjaAPI
from apps.accounts.api import router as accounts_router from apps.accounts.api import router as accounts_router
from apps.audit.api import router as audit_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.solutions.api import router as solutions_router
from apps.system.api import router as system_router from apps.system.api import router as system_router
from apps.tickets.api import router as tickets_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("/audit", audit_router)
api.add_router("/tickets", tickets_router) api.add_router("/tickets", tickets_router)
api.add_router("/solutions", solutions_router) api.add_router("/solutions", solutions_router)
api.add_router("/knowledge", knowledge_router)
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),