feat: add governed knowledge lifecycle

This commit is contained in:
hailin
2026-07-27 19:01:19 +08:00
parent 1a49ce4eae
commit 07c9ea1cc4
32 changed files with 5893 additions and 178 deletions

View File

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

View File

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

View 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]

View 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

View 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()