feat: add governed knowledge lifecycle
This commit is contained in:
@@ -1,4 +1,15 @@
|
||||
from apps.knowledge.models import KnowledgeBase, KnowledgeChunk, KnowledgeDocument, KnowledgeItem
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import override_settings
|
||||
|
||||
from apps.knowledge.documents import process_knowledge_document
|
||||
from apps.knowledge.models import (
|
||||
GovernedAsset,
|
||||
KnowledgeBase,
|
||||
KnowledgeChunk,
|
||||
KnowledgeDocument,
|
||||
KnowledgeItem,
|
||||
)
|
||||
from apps.tickets.materials import MaterialScanError
|
||||
|
||||
|
||||
def test_create_and_list_knowledge_base(admin_client, db):
|
||||
@@ -23,7 +34,7 @@ def test_create_and_list_knowledge_base(admin_client, db):
|
||||
assert response.json()[0]["name"] == "税务采集知识库"
|
||||
|
||||
|
||||
def test_create_knowledge_item_builds_chunks(admin_client, db):
|
||||
def test_create_knowledge_item_always_creates_governed_draft(admin_client, db):
|
||||
base = KnowledgeBase.objects.create(name="税务采集知识库", scope="数据采集")
|
||||
|
||||
response = admin_client.post(
|
||||
@@ -45,8 +56,33 @@ def test_create_knowledge_item_builds_chunks(admin_client, db):
|
||||
|
||||
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
|
||||
assert item.status == "draft"
|
||||
assert KnowledgeChunk.objects.filter(item=item).count() == 0
|
||||
assert GovernedAsset.objects.filter(
|
||||
asset_type="knowledge_item",
|
||||
object_id=item.id,
|
||||
versions__status="draft",
|
||||
).exists()
|
||||
|
||||
|
||||
def test_create_knowledge_item_rejects_unknown_category(admin_client, db):
|
||||
base = KnowledgeBase.objects.create(name="税务采集知识库", scope="数据采集")
|
||||
|
||||
response = admin_client.post(
|
||||
"/api/knowledge/items",
|
||||
data={
|
||||
"knowledge_base_id": base.id,
|
||||
"title": "未知分类知识",
|
||||
"problem": "分类编码不存在",
|
||||
"category_code": "unknown_category",
|
||||
"solution": "不应写入知识库",
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "知识适用分类不存在或已停用"
|
||||
assert not KnowledgeItem.objects.filter(title="未知分类知识").exists()
|
||||
|
||||
|
||||
def test_ingest_document_text_creates_document_and_chunks(admin_client, db):
|
||||
@@ -67,3 +103,223 @@ def test_ingest_document_text_creates_document_and_chunks(admin_client, db):
|
||||
document = KnowledgeDocument.objects.get(id=response.json()["id"])
|
||||
assert document.parse_status == "success"
|
||||
assert KnowledgeChunk.objects.filter(document=document).count() >= 1
|
||||
|
||||
|
||||
def test_upload_document_preserves_original_and_indexes_text(
|
||||
admin_client,
|
||||
db,
|
||||
monkeypatch,
|
||||
):
|
||||
base = KnowledgeBase.objects.create(name="文件知识库", scope="技术支持")
|
||||
objects = {}
|
||||
monkeypatch.setattr(
|
||||
"apps.knowledge.documents.put_object",
|
||||
lambda path, raw, mime_type: objects.update({path: raw}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"apps.knowledge.documents.get_object",
|
||||
lambda path: objects[path],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"apps.knowledge.api.dispatch_knowledge_document_processing",
|
||||
lambda document_id: process_knowledge_document(document_id),
|
||||
)
|
||||
|
||||
response = admin_client.post(
|
||||
f"/api/knowledge/documents/upload?knowledge_base_id={base.id}",
|
||||
data={
|
||||
"file": SimpleUploadedFile(
|
||||
"support.log",
|
||||
"接口超时,请检查连接池。".encode(),
|
||||
content_type="text/plain",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
document = KnowledgeDocument.objects.get(public_id=payload["public_id"])
|
||||
assert document.scan_status == "clean"
|
||||
assert document.parse_status == "success"
|
||||
assert document.storage_path in objects
|
||||
assert document.content_text == "接口超时,请检查连接池。"
|
||||
assert document.sha256
|
||||
assert document.file_size == len(objects[document.storage_path])
|
||||
assert document.processing_attempts == 1
|
||||
assert document.indexed_at is not None
|
||||
assert document.chunks.count() >= 1
|
||||
assert payload["has_original"] is True
|
||||
|
||||
listed = admin_client.get(f"/api/knowledge/documents?knowledge_base_id={base.id}")
|
||||
assert listed.status_code == 200
|
||||
assert listed.json()[0]["public_id"] == str(document.public_id)
|
||||
|
||||
|
||||
def test_upload_document_rejects_disguised_file(admin_client, db):
|
||||
base = KnowledgeBase.objects.create(name="文件知识库")
|
||||
|
||||
response = admin_client.post(
|
||||
f"/api/knowledge/documents/upload?knowledge_base_id={base.id}",
|
||||
data={
|
||||
"file": SimpleUploadedFile(
|
||||
"fake.pdf",
|
||||
b"not a pdf",
|
||||
content_type="application/pdf",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 415
|
||||
assert not KnowledgeDocument.objects.exists()
|
||||
|
||||
|
||||
def test_document_scan_failure_keeps_original_and_can_retry(
|
||||
admin_client,
|
||||
db,
|
||||
monkeypatch,
|
||||
):
|
||||
base = KnowledgeBase.objects.create(name="文件知识库")
|
||||
objects = {}
|
||||
monkeypatch.setattr(
|
||||
"apps.knowledge.documents.put_object",
|
||||
lambda path, raw, mime_type: objects.update({path: raw}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"apps.knowledge.documents.get_object",
|
||||
lambda path: objects[path],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"apps.knowledge.documents.scan_bytes",
|
||||
lambda raw: (_ for _ in ()).throw(MaterialScanError("扫描服务不可用")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"apps.knowledge.api.dispatch_knowledge_document_processing",
|
||||
lambda document_id: process_knowledge_document(document_id),
|
||||
)
|
||||
response = admin_client.post(
|
||||
f"/api/knowledge/documents/upload?knowledge_base_id={base.id}",
|
||||
data={
|
||||
"file": SimpleUploadedFile(
|
||||
"support.txt",
|
||||
"重试后可解析".encode(),
|
||||
content_type="text/plain",
|
||||
)
|
||||
},
|
||||
)
|
||||
document = KnowledgeDocument.objects.get(public_id=response.json()["public_id"])
|
||||
|
||||
assert response.status_code == 200
|
||||
assert document.scan_status == "failed"
|
||||
assert document.parse_status == "failed"
|
||||
assert document.storage_path in objects
|
||||
assert document.chunks.count() == 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
"apps.knowledge.documents.scan_bytes",
|
||||
lambda raw: {"status": "clean", "engine": "test"},
|
||||
)
|
||||
retried = admin_client.post(f"/api/knowledge/documents/{document.public_id}/retry")
|
||||
|
||||
assert retried.status_code == 200
|
||||
document.refresh_from_db()
|
||||
assert document.parse_status == "success"
|
||||
assert document.processing_attempts == 2
|
||||
assert document.chunks.count() >= 1
|
||||
process_knowledge_document(document.id)
|
||||
document.refresh_from_db()
|
||||
assert document.processing_attempts == 2
|
||||
|
||||
|
||||
def test_upload_document_storage_failure_does_not_create_record(
|
||||
admin_client,
|
||||
db,
|
||||
monkeypatch,
|
||||
):
|
||||
base = KnowledgeBase.objects.create(name="文件知识库")
|
||||
monkeypatch.setattr(
|
||||
"apps.knowledge.documents.put_object",
|
||||
lambda *args: (_ for _ in ()).throw(OSError("storage down")),
|
||||
)
|
||||
|
||||
response = admin_client.post(
|
||||
f"/api/knowledge/documents/upload?knowledge_base_id={base.id}",
|
||||
data={
|
||||
"file": SimpleUploadedFile(
|
||||
"support.txt",
|
||||
b"content",
|
||||
content_type="text/plain",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert not KnowledgeDocument.objects.exists()
|
||||
|
||||
|
||||
def test_download_uploaded_document_returns_original(
|
||||
admin_client,
|
||||
db,
|
||||
monkeypatch,
|
||||
):
|
||||
base = KnowledgeBase.objects.create(name="文件知识库")
|
||||
document = KnowledgeDocument.objects.create(
|
||||
knowledge_base=base,
|
||||
file_name="支持手册.txt",
|
||||
file_type="text",
|
||||
extension="txt",
|
||||
mime_type="text/plain",
|
||||
file_size=7,
|
||||
sha256="a" * 64,
|
||||
storage_path="knowledge-documents/manual.txt",
|
||||
scan_status="clean",
|
||||
parse_status="success",
|
||||
)
|
||||
monkeypatch.setattr("apps.knowledge.api.get_object", lambda path: b"content")
|
||||
|
||||
response = admin_client.get(f"/api/knowledge/documents/{document.public_id}/download")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.content == b"content"
|
||||
assert response["Content-Type"] == "text/plain"
|
||||
assert "attachment" in response["Content-Disposition"]
|
||||
|
||||
|
||||
def test_download_infected_document_is_blocked(admin_client, db, monkeypatch):
|
||||
base = KnowledgeBase.objects.create(name="文件知识库")
|
||||
document = KnowledgeDocument.objects.create(
|
||||
knowledge_base=base,
|
||||
file_name="可疑文件.txt",
|
||||
storage_path="knowledge-documents/infected.txt",
|
||||
scan_status="infected",
|
||||
parse_status="failed",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"apps.knowledge.api.get_object",
|
||||
lambda path: (_ for _ in ()).throw(AssertionError("隔离文件不应从对象存储读取")),
|
||||
)
|
||||
|
||||
response = admin_client.get(f"/api/knowledge/documents/{document.public_id}/download")
|
||||
|
||||
assert response.status_code == 423
|
||||
|
||||
|
||||
@override_settings(KNOWLEDGE_EMBEDDING_ALLOW_HASH_FALLBACK=False)
|
||||
def test_knowledge_draft_creation_does_not_require_embedding_deployment(admin_client, db):
|
||||
base = KnowledgeBase.objects.create(name="正式知识库")
|
||||
|
||||
response = admin_client.post(
|
||||
"/api/knowledge/items",
|
||||
data={
|
||||
"knowledge_base_id": base.id,
|
||||
"title": "订单超时",
|
||||
"problem": "订单接口超时",
|
||||
"solution": "检查下游连接池",
|
||||
"status": "published",
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
item = KnowledgeItem.objects.get(title="订单超时")
|
||||
assert item.status == "draft"
|
||||
assert item.chunks.count() == 0
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from apps.knowledge.models import KnowledgeBase, KnowledgeChunk, KnowledgeItem
|
||||
from apps.knowledge.services import retrieve_knowledge
|
||||
from apps.knowledge.services import rebuild_item_chunks, retrieve_knowledge
|
||||
from apps.modelhub.crypto import encrypt_secret
|
||||
from apps.modelhub.models import ModelDeployment, ModelProvider
|
||||
from apps.solutions.models import Solution
|
||||
|
||||
|
||||
@@ -51,3 +53,154 @@ def test_published_solution_can_be_indexed_for_retrieval(db):
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0].metadata["source_type"] == "solution"
|
||||
assert chunks[0].metadata["solution_id"] == solution.id
|
||||
|
||||
|
||||
def test_hybrid_retrieval_uses_configured_embedding_and_exposes_score_audit(
|
||||
db,
|
||||
monkeypatch,
|
||||
):
|
||||
provider = ModelProvider.objects.create(
|
||||
name="向量渠道",
|
||||
base_url="https://models.example.com/v1",
|
||||
api_key_encrypted=encrypt_secret("secret"),
|
||||
)
|
||||
deployment = ModelDeployment.objects.create(
|
||||
provider=provider,
|
||||
display_name="知识向量模型",
|
||||
model_name="embedding-model",
|
||||
capability="embedding",
|
||||
)
|
||||
vector = [0.0] * 1536
|
||||
vector[0] = 1.0
|
||||
calls = []
|
||||
|
||||
def request_embeddings(selected, contents, dimensions):
|
||||
calls.append((selected.id, contents, dimensions))
|
||||
return [vector[:] for _ in contents]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"apps.knowledge.embeddings._request_embeddings",
|
||||
request_embeddings,
|
||||
)
|
||||
base = KnowledgeBase.objects.create(name="正式知识库")
|
||||
item = KnowledgeItem.objects.create(
|
||||
knowledge_base=base,
|
||||
title="订单超时",
|
||||
problem="订单接口返回 TIMEOUT-504",
|
||||
solution="检查下游连接池",
|
||||
status="published",
|
||||
)
|
||||
chunks = rebuild_item_chunks(item)
|
||||
|
||||
results = retrieve_knowledge("订单 TIMEOUT-504", limit=1, published_only=True)
|
||||
|
||||
assert chunks[0].embedding_model == "embedding-model"
|
||||
assert chunks[0].embedding_vector is not None
|
||||
assert results[0]["metadata"]["retrieval"]["query_embedding_model"] == "embedding-model"
|
||||
assert results[0]["metadata"]["retrieval"]["query_embedding_deployment_id"] == deployment.id
|
||||
assert results[0]["metadata"]["retrieval"]["query_embedding_fallback"] is False
|
||||
assert results[0]["metadata"]["retrieval"]["channels"] == [
|
||||
"portable_text",
|
||||
"portable_vector",
|
||||
]
|
||||
assert calls[0][2] == 1536
|
||||
|
||||
|
||||
def test_configured_rerank_model_controls_final_order_and_is_audited(db, monkeypatch):
|
||||
provider = ModelProvider.objects.create(
|
||||
name="重排渠道",
|
||||
base_url="https://models.example.com/v1",
|
||||
api_key_encrypted=encrypt_secret("secret"),
|
||||
)
|
||||
deployment = ModelDeployment.objects.create(
|
||||
provider=provider,
|
||||
display_name="知识重排模型",
|
||||
model_name="rerank-model",
|
||||
capability="rerank",
|
||||
)
|
||||
base = KnowledgeBase.objects.create(name="重排知识库")
|
||||
first = KnowledgeItem.objects.create(
|
||||
knowledge_base=base,
|
||||
title="订单超时基础方案",
|
||||
problem="订单 TIMEOUT-504",
|
||||
solution="检查连接池",
|
||||
status="published",
|
||||
)
|
||||
preferred = KnowledgeItem.objects.create(
|
||||
knowledge_base=base,
|
||||
title="订单超时权威方案",
|
||||
problem="订单 TIMEOUT-504",
|
||||
solution="按权威步骤恢复连接池",
|
||||
status="published",
|
||||
)
|
||||
rebuild_item_chunks(first)
|
||||
rebuild_item_chunks(preferred)
|
||||
|
||||
def rerank(selected, query, candidates):
|
||||
assert selected.id == deployment.id
|
||||
return {
|
||||
item["id"]: 1.0 if item["title"] == preferred.title else 0.0
|
||||
for item in candidates
|
||||
}
|
||||
|
||||
monkeypatch.setattr("apps.knowledge.reranking._request_rerank", rerank)
|
||||
|
||||
results = retrieve_knowledge("订单 TIMEOUT-504", limit=2, published_only=True)
|
||||
|
||||
assert results[0]["metadata"]["item_id"] == preferred.id
|
||||
audit = results[0]["metadata"]["retrieval"]["rerank"]
|
||||
assert audit["status"] == "succeeded"
|
||||
assert audit["model_name"] == "rerank-model"
|
||||
assert audit["deployment_id"] == deployment.id
|
||||
assert audit["model_weight"] == 0.6
|
||||
|
||||
|
||||
def test_configured_rerank_failure_falls_back_to_weighted_order_and_is_audited(
|
||||
db,
|
||||
monkeypatch,
|
||||
):
|
||||
provider = ModelProvider.objects.create(
|
||||
name="异常重排渠道",
|
||||
base_url="https://models.example.com/v1",
|
||||
api_key_encrypted=encrypt_secret("secret"),
|
||||
)
|
||||
deployment = ModelDeployment.objects.create(
|
||||
provider=provider,
|
||||
display_name="异常知识重排模型",
|
||||
model_name="broken-rerank-model",
|
||||
capability="rerank",
|
||||
)
|
||||
base = KnowledgeBase.objects.create(name="重排降级知识库")
|
||||
relevant = KnowledgeItem.objects.create(
|
||||
knowledge_base=base,
|
||||
title="订单 TIMEOUT-504 处理",
|
||||
problem="订单 TIMEOUT-504",
|
||||
solution="检查连接池",
|
||||
status="published",
|
||||
)
|
||||
unrelated = KnowledgeItem.objects.create(
|
||||
knowledge_base=base,
|
||||
title="账号登录失败",
|
||||
problem="账号密码错误",
|
||||
solution="重置密码",
|
||||
status="published",
|
||||
)
|
||||
rebuild_item_chunks(relevant)
|
||||
rebuild_item_chunks(unrelated)
|
||||
|
||||
def failed_rerank(*_args, **_kwargs):
|
||||
raise TimeoutError("rerank unavailable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"apps.knowledge.reranking._request_rerank",
|
||||
failed_rerank,
|
||||
)
|
||||
|
||||
results = retrieve_knowledge("订单 TIMEOUT-504", limit=2, published_only=True)
|
||||
|
||||
assert results[0]["metadata"]["item_id"] == relevant.id
|
||||
audit = results[0]["metadata"]["retrieval"]["rerank"]
|
||||
assert audit["status"] == "failed_fallback"
|
||||
assert audit["model_name"] == "broken-rerank-model"
|
||||
assert audit["deployment_id"] == deployment.id
|
||||
assert audit["model_weight"] == 0.0
|
||||
|
||||
136
backend/apps/knowledge/tests/test_stage_3_2.py
Normal file
136
backend/apps/knowledge/tests/test_stage_3_2.py
Normal file
@@ -0,0 +1,136 @@
|
||||
import pytest
|
||||
|
||||
from apps.knowledge.models import KnowledgeBase, KnowledgeChunk, KnowledgeItem
|
||||
from apps.knowledge.services import (
|
||||
index_resolved_ticket,
|
||||
rebuild_item_chunks,
|
||||
retrieve_knowledge,
|
||||
sync_ticket_search_index,
|
||||
)
|
||||
from apps.solutions.models import TicketResolution
|
||||
from apps.tickets.models import Ticket, TicketCategory
|
||||
from apps.tickets.services import (
|
||||
set_ticket_archived,
|
||||
transition_ticket,
|
||||
update_ticket_fields,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_hybrid_retrieval_applies_structured_scope_before_reranking():
|
||||
base = KnowledgeBase.objects.create(name="结构化知识库")
|
||||
matching = KnowledgeItem.objects.create(
|
||||
knowledge_base=base,
|
||||
title="订单超时处理",
|
||||
problem="订单接口返回 TIMEOUT-504",
|
||||
solution="检查订单连接池",
|
||||
status="published",
|
||||
category_code="technical_issue",
|
||||
related_system="订单系统",
|
||||
environment_scope="生产环境",
|
||||
)
|
||||
out_of_scope = KnowledgeItem.objects.create(
|
||||
knowledge_base=base,
|
||||
title="支付超时处理",
|
||||
problem="支付接口返回 TIMEOUT-504",
|
||||
solution="检查支付连接池",
|
||||
status="published",
|
||||
category_code="data_issue",
|
||||
related_system="支付系统",
|
||||
environment_scope="生产环境",
|
||||
)
|
||||
rebuild_item_chunks(matching)
|
||||
rebuild_item_chunks(out_of_scope)
|
||||
|
||||
results = retrieve_knowledge(
|
||||
"TIMEOUT-504 连接池",
|
||||
published_only=True,
|
||||
scope={
|
||||
"category_code": "technical_issue",
|
||||
"related_system": "订单系统",
|
||||
"environment": "生产环境",
|
||||
},
|
||||
)
|
||||
|
||||
item_ids = {item["metadata"].get("item_id") for item in results}
|
||||
assert matching.id in item_ids
|
||||
assert out_of_scope.id not in item_ids
|
||||
assert results[0]["metadata"]["retrieval"]["scope_score"] == 1.0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_resolved_ticket_vector_index_is_persistent_and_archival_removes_it():
|
||||
category, _ = TicketCategory.objects.get_or_create(
|
||||
code="technical_issue",
|
||||
defaults={"name": "技术问题"},
|
||||
)
|
||||
ticket = Ticket.objects.create(
|
||||
ticket_no="TK202607270301",
|
||||
title="订单接口历史超时",
|
||||
description="订单接口返回 TIMEOUT-504",
|
||||
status="resolved",
|
||||
category=category,
|
||||
related_system="订单系统",
|
||||
environment="生产环境",
|
||||
)
|
||||
TicketResolution.objects.create(
|
||||
ticket=ticket,
|
||||
root_cause="连接池耗尽",
|
||||
solution_text="恢复订单连接池",
|
||||
)
|
||||
|
||||
chunks = index_resolved_ticket(ticket)
|
||||
results = retrieve_knowledge(
|
||||
"订单 TIMEOUT-504",
|
||||
published_only=True,
|
||||
scope={
|
||||
"category_code": "technical_issue",
|
||||
"related_system": "订单系统",
|
||||
"environment": "生产环境",
|
||||
},
|
||||
)
|
||||
|
||||
assert chunks[0].metadata["historical_ticket_id"] == ticket.id
|
||||
assert chunks[0].historical_ticket_id == ticket.id
|
||||
assert any(
|
||||
item["metadata"].get("historical_ticket_id") == ticket.id for item in results
|
||||
)
|
||||
|
||||
ticket.archived_at = ticket.updated_at
|
||||
ticket.save(update_fields=["archived_at", "updated_at"])
|
||||
assert sync_ticket_search_index(ticket.id) == 0
|
||||
assert not KnowledgeChunk.objects.filter(
|
||||
metadata__historical_ticket_id=ticket.id
|
||||
).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ticket_transition_and_archive_schedule_index_sync(
|
||||
django_capture_on_commit_callbacks,
|
||||
monkeypatch,
|
||||
):
|
||||
category, _ = TicketCategory.objects.get_or_create(
|
||||
code="technical_issue",
|
||||
defaults={"name": "技术问题"},
|
||||
)
|
||||
ticket = Ticket.objects.create(
|
||||
ticket_no="TK202607270302",
|
||||
title="订单接口超时",
|
||||
description="订单接口返回 TIMEOUT-504",
|
||||
status="pending_confirm",
|
||||
category=category,
|
||||
)
|
||||
dispatched = []
|
||||
monkeypatch.setattr(
|
||||
"apps.knowledge.tasks.dispatch_ticket_search_index",
|
||||
lambda ticket_id: dispatched.append(ticket_id),
|
||||
)
|
||||
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
transition_ticket(ticket, "resolved", resolution="恢复订单连接池")
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
update_ticket_fields(ticket, {"title": "订单接口超时(已复核)"})
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
set_ticket_archived(ticket, True)
|
||||
|
||||
assert dispatched == [ticket.id, ticket.id, ticket.id]
|
||||
260
backend/apps/knowledge/tests/test_stage_4_distillation.py
Normal file
260
backend/apps/knowledge/tests/test_stage_4_distillation.py
Normal file
@@ -0,0 +1,260 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management import call_command
|
||||
|
||||
from apps.agents.tests.helpers import create_deployment
|
||||
from apps.knowledge.distillation import (
|
||||
process_knowledge_distillation_result,
|
||||
start_ticket_knowledge_distillation,
|
||||
validate_knowledge_distillation_output,
|
||||
)
|
||||
from apps.knowledge.models import (
|
||||
GovernedAsset,
|
||||
KnowledgeBase,
|
||||
KnowledgeItem,
|
||||
TicketKnowledgeDistillation,
|
||||
TroubleshootingPlan,
|
||||
)
|
||||
from apps.solutions.models import Solution, TicketResolution
|
||||
from apps.tickets.models import Ticket
|
||||
from apps.tickets.services import transition_ticket
|
||||
from apps.workitems.models import WorkItem, WorkItemType
|
||||
|
||||
|
||||
def publish_distillation_agent():
|
||||
deployment = create_deployment()
|
||||
call_command("publish_knowledge_distillation", deployment_id=deployment.id)
|
||||
|
||||
|
||||
def distillation_output(*, citation: int | None = None) -> dict:
|
||||
return {
|
||||
"should_distill": True,
|
||||
"reason": "问题、根因与解决步骤完整,具有跨客户复用价值。",
|
||||
"assessment": {
|
||||
"novelty_score": 0.8,
|
||||
"reuse_value_score": 0.9,
|
||||
"completeness_score": 0.95,
|
||||
"duplicate_risk_score": 0.1,
|
||||
},
|
||||
"citations": [citation] if citation is not None else [],
|
||||
"knowledge_draft": {
|
||||
"title": "订单接口超时处理",
|
||||
"problem": "订单接口持续返回 TIMEOUT-504",
|
||||
"applicable_scope": "订单系统接口",
|
||||
"error_code": "TIMEOUT-504",
|
||||
"root_cause": "连接池容量不足",
|
||||
"troubleshooting_steps": "检查连接池使用率并核对慢请求",
|
||||
"solution": "调整连接池容量并验证错误率恢复",
|
||||
"risk_note": "变更前应确认容量上限",
|
||||
},
|
||||
"troubleshooting_plan_draft": {
|
||||
"title": "订单接口超时排查",
|
||||
"problem_pattern": "订单接口持续超时",
|
||||
"applicable_scope": "订单系统接口",
|
||||
"decision_rules": "先检查连接池,再检查下游依赖",
|
||||
"troubleshooting_steps": "1. 检查连接池\n2. 检查下游接口",
|
||||
"risk_note": "生产变更需审批",
|
||||
},
|
||||
"solution_draft": {
|
||||
"create": True,
|
||||
"title": "订单连接池恢复方案",
|
||||
"problem_signature": "订单接口 TIMEOUT-504",
|
||||
"root_cause": "连接池容量不足",
|
||||
"solution": "调整连接池容量并观察错误率",
|
||||
"risk_note": "生产变更需审批",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_resolved_ticket_distillation_creates_governed_drafts_idempotently(
|
||||
django_capture_on_commit_callbacks,
|
||||
monkeypatch,
|
||||
):
|
||||
publish_distillation_agent()
|
||||
monkeypatch.setattr(
|
||||
"apps.agents.tasks.execute_agent_run.delay",
|
||||
lambda run_id: SimpleNamespace(id=f"task-{run_id}"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"apps.knowledge.distillation.retrieve_support_references",
|
||||
lambda ticket: (
|
||||
ticket.title,
|
||||
[
|
||||
{
|
||||
"rank": 1,
|
||||
"source_type": "knowledge",
|
||||
"source_id": 9,
|
||||
"source_version": "v1",
|
||||
"title": "已有知识",
|
||||
"excerpt": "相似但不重复",
|
||||
"score": 0.4,
|
||||
}
|
||||
],
|
||||
),
|
||||
)
|
||||
actor = get_user_model().objects.create_user(username="distillation-owner")
|
||||
KnowledgeBase.objects.create(name="知识沉淀库")
|
||||
ticket = Ticket.objects.create(
|
||||
ticket_no="TK-DISTILL-001",
|
||||
title="订单接口超时",
|
||||
description="订单接口持续返回 TIMEOUT-504",
|
||||
status="resolved",
|
||||
related_system="订单系统",
|
||||
environment="生产环境",
|
||||
)
|
||||
TicketResolution.objects.create(
|
||||
ticket=ticket,
|
||||
root_cause="连接池容量不足",
|
||||
solution_text="调整连接池容量后恢复",
|
||||
confirmed_by=actor,
|
||||
)
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
distillation = start_ticket_knowledge_distillation(
|
||||
ticket,
|
||||
created_by=actor,
|
||||
)
|
||||
run = distillation.run
|
||||
run.status = "succeeded"
|
||||
run.final_output = distillation_output(citation=1)
|
||||
run.save(update_fields=["status", "final_output"])
|
||||
validate_knowledge_distillation_output(run, run.final_output)
|
||||
|
||||
processed = process_knowledge_distillation_result(run)
|
||||
processed_again = process_knowledge_distillation_result(run)
|
||||
|
||||
assert processed_again.id == processed.id
|
||||
assert KnowledgeItem.objects.count() == 1
|
||||
assert TroubleshootingPlan.objects.count() == 1
|
||||
assert Solution.objects.count() == 1
|
||||
assert processed.knowledge_item.source_type == "agent"
|
||||
assert processed.knowledge_item.source_ticket_id == ticket.id
|
||||
assert processed.troubleshooting_plan.source_ticket_id == ticket.id
|
||||
assert processed.solution.allow_auto_resolve is False
|
||||
assert processed.solution.requires_human_action is True
|
||||
assert GovernedAsset.objects.filter(
|
||||
asset_type="knowledge_item",
|
||||
object_id=processed.knowledge_item_id,
|
||||
versions__status="draft",
|
||||
).exists()
|
||||
assert GovernedAsset.objects.filter(
|
||||
asset_type="troubleshooting_plan",
|
||||
object_id=processed.troubleshooting_plan_id,
|
||||
versions__status="draft",
|
||||
).exists()
|
||||
assert GovernedAsset.objects.filter(
|
||||
asset_type="solution",
|
||||
object_id=processed.solution_id,
|
||||
versions__status="draft",
|
||||
).exists()
|
||||
assert ticket.events.filter(
|
||||
event_type="knowledge_distillation_completed",
|
||||
agent_run_id=run.id,
|
||||
).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_distillation_rejects_citation_outside_frozen_references(
|
||||
django_capture_on_commit_callbacks,
|
||||
monkeypatch,
|
||||
):
|
||||
publish_distillation_agent()
|
||||
monkeypatch.setattr(
|
||||
"apps.agents.tasks.execute_agent_run.delay",
|
||||
lambda run_id: SimpleNamespace(id=f"task-{run_id}"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"apps.knowledge.distillation.retrieve_support_references",
|
||||
lambda ticket: (ticket.title, []),
|
||||
)
|
||||
KnowledgeBase.objects.create(name="知识库")
|
||||
ticket = Ticket.objects.create(
|
||||
ticket_no="TK-DISTILL-002",
|
||||
title="登录失败",
|
||||
description="登录返回 AUTH-401",
|
||||
status="resolved",
|
||||
)
|
||||
TicketResolution.objects.create(ticket=ticket, solution_text="重新授权")
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
distillation = start_ticket_knowledge_distillation(ticket)
|
||||
output = distillation_output(citation=99)
|
||||
|
||||
with pytest.raises(ValueError, match="无效引用"):
|
||||
validate_knowledge_distillation_output(distillation.run, output)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_resolved_transition_schedules_distillation_after_commit(
|
||||
django_capture_on_commit_callbacks,
|
||||
monkeypatch,
|
||||
):
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
"apps.knowledge.tasks.dispatch_ticket_knowledge_distillation",
|
||||
lambda ticket_id, actor_id=None: calls.append((ticket_id, actor_id)),
|
||||
)
|
||||
actor = get_user_model().objects.create_user(username="resolver")
|
||||
ticket = Ticket.objects.create(
|
||||
ticket_no="TK-DISTILL-003",
|
||||
title="同步任务失败",
|
||||
description="同步任务持续失败",
|
||||
status="pending_confirm",
|
||||
)
|
||||
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
transition_ticket(
|
||||
ticket,
|
||||
"resolved",
|
||||
operator=actor,
|
||||
resolution="修复错误配置后恢复",
|
||||
root_cause="配置错误",
|
||||
)
|
||||
|
||||
assert calls == [(ticket.id, actor.id)]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_asset_submission_creates_unique_review_task_and_review_completes_it(
|
||||
admin_client,
|
||||
):
|
||||
base = KnowledgeBase.objects.create(name="审核任务知识库")
|
||||
response = admin_client.post(
|
||||
"/api/knowledge/items",
|
||||
{
|
||||
"knowledge_base_id": base.id,
|
||||
"title": "缓存异常",
|
||||
"problem": "缓存读取失败",
|
||||
"solution": "重建缓存",
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
asset = GovernedAsset.objects.get(
|
||||
asset_type="knowledge_item",
|
||||
object_id=response.json()["id"],
|
||||
)
|
||||
admin_client.post(f"/api/knowledge/assets/{asset.public_id}/versions/1/submit")
|
||||
admin_client.post(f"/api/knowledge/assets/{asset.public_id}/versions/1/submit")
|
||||
task = WorkItem.objects.get(task_type=WorkItemType.ASSET_REVIEW)
|
||||
assert task.status == "active"
|
||||
assert task.payload["asset_public_id"] == str(asset.public_id)
|
||||
|
||||
reviewer = get_user_model().objects.create_superuser(
|
||||
username="independent-reviewer",
|
||||
email="reviewer@example.com",
|
||||
password="test-password",
|
||||
)
|
||||
from django.test import Client
|
||||
|
||||
reviewer_client = Client()
|
||||
reviewer_client.force_login(reviewer)
|
||||
response = reviewer_client.post(
|
||||
f"/api/knowledge/assets/{asset.public_id}/versions/1/reject",
|
||||
{"comment": "需补充适用范围"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
task.refresh_from_db()
|
||||
assert task.status == "completed"
|
||||
assert TicketKnowledgeDistillation.objects.count() == 0
|
||||
205
backend/apps/knowledge/tests/test_stage_4_governance.py
Normal file
205
backend/apps/knowledge/tests/test_stage_4_governance.py
Normal file
@@ -0,0 +1,205 @@
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client
|
||||
|
||||
from apps.knowledge.models import (
|
||||
GovernedAsset,
|
||||
KnowledgeBase,
|
||||
KnowledgeChunk,
|
||||
KnowledgeItem,
|
||||
TroubleshootingPlan,
|
||||
)
|
||||
from apps.solutions.models import Solution
|
||||
|
||||
|
||||
def reviewer_client():
|
||||
reviewer = get_user_model().objects.create_superuser(
|
||||
username="knowledge-reviewer",
|
||||
email="knowledge-reviewer@example.com",
|
||||
password="test-password",
|
||||
)
|
||||
client = Client()
|
||||
client.force_login(reviewer)
|
||||
return client, reviewer
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_knowledge_asset_requires_independent_review_and_published_is_immutable(
|
||||
admin_client,
|
||||
django_capture_on_commit_callbacks,
|
||||
):
|
||||
base = KnowledgeBase.objects.create(name="审核知识库")
|
||||
response = admin_client.post(
|
||||
"/api/knowledge/items",
|
||||
{
|
||||
"knowledge_base_id": base.id,
|
||||
"title": "登录白屏处理",
|
||||
"problem": "登录后页面白屏",
|
||||
"solution": "清理损坏缓存并重新登录",
|
||||
"status": "published",
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
item = KnowledgeItem.objects.get(id=response.json()["id"])
|
||||
assert item.status == "draft"
|
||||
assert item.chunks.count() == 0
|
||||
asset = GovernedAsset.objects.get(asset_type="knowledge_item", object_id=item.id)
|
||||
|
||||
response = admin_client.post(
|
||||
f"/api/knowledge/assets/{asset.public_id}/versions/1/submit"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "pending_review"
|
||||
|
||||
response = admin_client.post(
|
||||
f"/api/knowledge/assets/{asset.public_id}/versions/1/approve",
|
||||
{"comment": "自审"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "不能审核自己" in response.json()["detail"]
|
||||
|
||||
reviewer, reviewer_user = reviewer_client()
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
response = reviewer.post(
|
||||
f"/api/knowledge/assets/{asset.public_id}/versions/1/approve",
|
||||
{"comment": "内容完整,可以发布"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
item.refresh_from_db()
|
||||
asset.refresh_from_db()
|
||||
assert item.status == "published"
|
||||
assert item.reviewed_by == reviewer_user
|
||||
assert asset.current_published_version.version == 1
|
||||
assert KnowledgeChunk.objects.filter(item=item).exists()
|
||||
|
||||
response = admin_client.patch(
|
||||
f"/api/knowledge/assets/{asset.public_id}/versions/1",
|
||||
{
|
||||
"content": asset.current_published_version.content,
|
||||
"change_summary": "试图修改发布版本",
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "只有草稿或已驳回版本可以编辑" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_new_version_compare_offline_and_rollback_create_traceable_versions(
|
||||
admin_client,
|
||||
):
|
||||
base = KnowledgeBase.objects.create(name="版本知识库")
|
||||
item = KnowledgeItem.objects.create(
|
||||
knowledge_base=base,
|
||||
title="原始标题",
|
||||
problem="原始问题",
|
||||
solution="原始方案",
|
||||
created_by=get_user_model().objects.get(is_superuser=True),
|
||||
)
|
||||
from apps.knowledge.governance import ensure_governed_asset
|
||||
|
||||
asset = ensure_governed_asset(
|
||||
"knowledge_item",
|
||||
item,
|
||||
actor=item.created_by,
|
||||
)
|
||||
admin_client.post(f"/api/knowledge/assets/{asset.public_id}/versions/1/submit")
|
||||
reviewer, _ = reviewer_client()
|
||||
reviewer.post(
|
||||
f"/api/knowledge/assets/{asset.public_id}/versions/1/approve",
|
||||
{},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
response = admin_client.post(
|
||||
f"/api/knowledge/assets/{asset.public_id}/revisions",
|
||||
{"change_summary": "补充新版内容"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["version"] == 2
|
||||
content = response.json()["content"]
|
||||
content["title"] = "新版标题"
|
||||
response = admin_client.patch(
|
||||
f"/api/knowledge/assets/{asset.public_id}/versions/2",
|
||||
{"content": content, "change_summary": "更新标题"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
admin_client.post(f"/api/knowledge/assets/{asset.public_id}/versions/2/submit")
|
||||
reviewer.post(
|
||||
f"/api/knowledge/assets/{asset.public_id}/versions/2/approve",
|
||||
{},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
response = admin_client.get(
|
||||
f"/api/knowledge/assets/{asset.public_id}/compare?left=1&right=2"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["changes"] == [
|
||||
{"field": "title", "before": "原始标题", "after": "新版标题"}
|
||||
]
|
||||
asset.refresh_from_db()
|
||||
assert asset.versions.get(version=1).status == "offline"
|
||||
assert asset.current_published_version.version == 2
|
||||
|
||||
response = admin_client.post(
|
||||
f"/api/knowledge/assets/{asset.public_id}/rollback",
|
||||
{"source_version": 1, "change_summary": "恢复稳定版本"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["version"] == 3
|
||||
assert response.json()["status"] == "draft"
|
||||
assert response.json()["source_version"] == 1
|
||||
assert response.json()["content"]["title"] == "原始标题"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_solution_and_troubleshooting_plan_share_governance_lifecycle(admin_client):
|
||||
base = KnowledgeBase.objects.create(name="统一资产库")
|
||||
solution_response = admin_client.post(
|
||||
"/api/solutions",
|
||||
{
|
||||
"title": "订单恢复方案",
|
||||
"problem_signature": "订单状态未更新",
|
||||
"root_cause": "回写延迟",
|
||||
"solution": "重新同步状态",
|
||||
"status": "published",
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
plan_response = admin_client.post(
|
||||
"/api/knowledge/troubleshooting-plans",
|
||||
{
|
||||
"knowledge_base_id": base.id,
|
||||
"title": "订单状态排查",
|
||||
"problem_pattern": "订单长时间未更新",
|
||||
"decision_rules": "先检查回写队列,再检查下游接口",
|
||||
"troubleshooting_steps": "1. 检查队列\n2. 检查接口",
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert solution_response.status_code == 200
|
||||
assert plan_response.status_code == 200
|
||||
solution = Solution.objects.get(id=solution_response.json()["id"])
|
||||
plan = TroubleshootingPlan.objects.get(id=plan_response.json()["id"])
|
||||
assert solution.status == "draft"
|
||||
assert plan.status == "draft"
|
||||
assert GovernedAsset.objects.filter(
|
||||
asset_type="solution",
|
||||
object_id=solution.id,
|
||||
versions__version=1,
|
||||
versions__status="draft",
|
||||
).exists()
|
||||
assert GovernedAsset.objects.filter(
|
||||
asset_type="troubleshooting_plan",
|
||||
object_id=plan.id,
|
||||
versions__version=1,
|
||||
versions__status="draft",
|
||||
).exists()
|
||||
Reference in New Issue
Block a user