feat: complete knowledge source metadata loop
This commit is contained in:
@@ -1,12 +1,15 @@
|
||||
from typing import Optional
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, Request, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.reviews import require_current_user
|
||||
from app.clients.shared_ai_platform import SharedAIPlatformClient, get_shared_ai_platform_client
|
||||
from app.core.response import success_response
|
||||
from app.db import get_db_session
|
||||
from app.models.knowledge_base_document import KnowledgeBaseDocument
|
||||
from app.repositories.knowledge_base_document_repository import KnowledgeBaseDocumentRepository
|
||||
from app.services.audit_log_service import AuditLogService
|
||||
|
||||
router = APIRouter(prefix="/knowledge-bases", tags=["knowledge-bases"])
|
||||
@@ -24,6 +27,121 @@ def get_audit_log_service(db: Session = Depends(get_db_session)) -> AuditLogServ
|
||||
return AuditLogService(db)
|
||||
|
||||
|
||||
def _parse_tags_json(tags_json: Optional[str]) -> Optional[list[str]]:
|
||||
if tags_json is None or not str(tags_json).strip():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(tags_json)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(status_code=400, detail="tags_json must be a valid JSON array") from exc
|
||||
if not isinstance(payload, list) or not all(isinstance(item, str) for item in payload):
|
||||
raise HTTPException(status_code=400, detail="tags_json must be a JSON array of strings")
|
||||
normalized = [item.strip() for item in payload if item and item.strip()]
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _build_metadata_override(
|
||||
*,
|
||||
source_type: Optional[str],
|
||||
authority: Optional[str],
|
||||
effective_date: Optional[str],
|
||||
region: Optional[str],
|
||||
tags: Optional[list[str]],
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {}
|
||||
if source_type and source_type.strip():
|
||||
payload["source_type"] = source_type.strip()
|
||||
if authority and authority.strip():
|
||||
payload["authority"] = authority.strip()
|
||||
if effective_date and effective_date.strip():
|
||||
payload["effective_date"] = effective_date.strip()
|
||||
if region and region.strip():
|
||||
payload["region"] = region.strip()
|
||||
if tags is not None:
|
||||
payload["tags"] = tags
|
||||
return payload
|
||||
|
||||
|
||||
def _merge_local_document_overlay(remote_item: dict[str, Any], local_item: KnowledgeBaseDocument) -> dict[str, Any]:
|
||||
merged = dict(remote_item)
|
||||
if local_item.source_type:
|
||||
merged["source_type"] = local_item.source_type
|
||||
if local_item.authority:
|
||||
merged["authority"] = local_item.authority
|
||||
if local_item.effective_date:
|
||||
merged["effective_date"] = local_item.effective_date
|
||||
if local_item.region:
|
||||
merged["region"] = local_item.region
|
||||
if local_item.tags_json is not None:
|
||||
merged["tags"] = local_item.tags_json
|
||||
merged["tags_json"] = local_item.tags_json
|
||||
return merged
|
||||
|
||||
|
||||
def _upsert_local_document_overlay(
|
||||
db: Session,
|
||||
*,
|
||||
current_user: dict,
|
||||
remote_item: dict[str, Any],
|
||||
metadata_override: Optional[dict[str, Any]] = None,
|
||||
) -> dict[str, Any]:
|
||||
repository = KnowledgeBaseDocumentRepository(db)
|
||||
tenant_id = str(current_user.get("tenant_id") or "")
|
||||
knowledge_base_id = str(remote_item.get("knowledge_base_id") or "")
|
||||
document_id = str(remote_item.get("id") or "")
|
||||
if not tenant_id or not knowledge_base_id or not document_id:
|
||||
return remote_item
|
||||
|
||||
existing = repository.get_by_id(document_id, tenant_id=tenant_id, knowledge_base_id=knowledge_base_id)
|
||||
overlay = metadata_override or {}
|
||||
|
||||
source_type = overlay.get("source_type") or remote_item.get("source_type") or (existing.source_type if existing else None)
|
||||
authority = overlay.get("authority") or remote_item.get("authority") or (existing.authority if existing else None)
|
||||
effective_date = overlay.get("effective_date") or remote_item.get("effective_date") or (existing.effective_date if existing else None)
|
||||
region = overlay.get("region") or remote_item.get("region") or (existing.region if existing else None)
|
||||
tags = overlay.get("tags")
|
||||
if tags is None:
|
||||
tags = remote_item.get("tags")
|
||||
if tags is None:
|
||||
tags = remote_item.get("tags_json")
|
||||
if tags is None and existing is not None:
|
||||
tags = existing.tags_json
|
||||
|
||||
if existing is None:
|
||||
existing = KnowledgeBaseDocument(
|
||||
id=document_id,
|
||||
knowledge_base_id=knowledge_base_id,
|
||||
tenant_id=tenant_id,
|
||||
file_name=str(remote_item.get("file_name") or ""),
|
||||
mime_type=remote_item.get("mime_type"),
|
||||
file_size=int(remote_item.get("file_size") or 0),
|
||||
status=str(remote_item.get("status") or "indexed"),
|
||||
source_type=source_type,
|
||||
authority=authority,
|
||||
effective_date=effective_date,
|
||||
region=region,
|
||||
tags_json=tags,
|
||||
content_excerpt=remote_item.get("content_excerpt"),
|
||||
error_message=remote_item.get("error_message"),
|
||||
)
|
||||
repository.create(existing)
|
||||
else:
|
||||
existing.file_name = str(remote_item.get("file_name") or existing.file_name)
|
||||
existing.mime_type = remote_item.get("mime_type") or existing.mime_type
|
||||
existing.file_size = int(remote_item.get("file_size") or existing.file_size or 0)
|
||||
existing.status = str(remote_item.get("status") or existing.status or "indexed")
|
||||
existing.source_type = source_type
|
||||
existing.authority = authority
|
||||
existing.effective_date = effective_date
|
||||
existing.region = region
|
||||
existing.tags_json = tags
|
||||
existing.content_excerpt = remote_item.get("content_excerpt")
|
||||
existing.error_message = remote_item.get("error_message")
|
||||
repository.update(existing)
|
||||
|
||||
return _merge_local_document_overlay(remote_item, existing)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_knowledge_bases(
|
||||
page: int = 1,
|
||||
@@ -66,8 +184,23 @@ def list_knowledge_base_documents(
|
||||
page_size: int = 20,
|
||||
shared_ai_platform_client: SharedAIPlatformClient = Depends(get_shared_ai_platform_client),
|
||||
current_user: dict = Depends(require_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
return success_response(shared_ai_platform_client.list_documents(current_user, knowledge_base_id, page, page_size))
|
||||
payload = shared_ai_platform_client.list_documents(current_user, knowledge_base_id, page, page_size)
|
||||
items = payload.get("items", [])
|
||||
if not isinstance(items, list) or not items:
|
||||
return success_response(payload)
|
||||
|
||||
repository = KnowledgeBaseDocumentRepository(db)
|
||||
document_ids = [str(item.get("id") or "") for item in items if item.get("id")]
|
||||
overlays = repository.list_by_ids(str(current_user.get("tenant_id") or ""), document_ids)
|
||||
overlay_map = {overlay.id: overlay for overlay in overlays}
|
||||
|
||||
payload["items"] = [
|
||||
_merge_local_document_overlay(item, overlay_map[item["id"]]) if item.get("id") in overlay_map else item
|
||||
for item in items
|
||||
]
|
||||
return success_response(payload)
|
||||
|
||||
|
||||
@router.post("/{knowledge_base_id}/documents")
|
||||
@@ -76,12 +209,31 @@ async def upload_knowledge_base_document(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
note: Optional[str] = Form(default=None),
|
||||
source_type: Optional[str] = Form(default=None),
|
||||
authority: Optional[str] = Form(default=None),
|
||||
effective_date: Optional[str] = Form(default=None),
|
||||
region: Optional[str] = Form(default=None),
|
||||
tags_json: Optional[str] = Form(default=None),
|
||||
shared_ai_platform_client: SharedAIPlatformClient = Depends(get_shared_ai_platform_client),
|
||||
audit_log_service: AuditLogService = Depends(get_audit_log_service),
|
||||
current_user: dict = Depends(require_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
parsed_tags = _parse_tags_json(tags_json)
|
||||
content = await file.read()
|
||||
result = shared_ai_platform_client.upload_document(current_user, knowledge_base_id, file.filename, file.content_type, content)
|
||||
result = _upsert_local_document_overlay(
|
||||
db,
|
||||
current_user=current_user,
|
||||
remote_item=result,
|
||||
metadata_override=_build_metadata_override(
|
||||
source_type=source_type,
|
||||
authority=authority,
|
||||
effective_date=effective_date,
|
||||
region=region,
|
||||
tags=parsed_tags,
|
||||
),
|
||||
)
|
||||
audit_log_service.record_event(
|
||||
tenant_id=current_user.get("tenant_id"),
|
||||
user_id=current_user.get("id"),
|
||||
@@ -99,6 +251,11 @@ async def upload_knowledge_base_document(
|
||||
"index_status": result.get("index_status"),
|
||||
"error_message": result.get("error_message"),
|
||||
"note": note,
|
||||
"source_type": result.get("source_type"),
|
||||
"authority": result.get("authority"),
|
||||
"effective_date": result.get("effective_date"),
|
||||
"region": result.get("region"),
|
||||
"tags": result.get("tags"),
|
||||
},
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
@@ -113,10 +270,17 @@ async def retry_knowledge_base_document(
|
||||
request: Request,
|
||||
file: Optional[UploadFile] = File(default=None),
|
||||
note: Optional[str] = Form(default=None),
|
||||
source_type: Optional[str] = Form(default=None),
|
||||
authority: Optional[str] = Form(default=None),
|
||||
effective_date: Optional[str] = Form(default=None),
|
||||
region: Optional[str] = Form(default=None),
|
||||
tags_json: Optional[str] = Form(default=None),
|
||||
shared_ai_platform_client: SharedAIPlatformClient = Depends(get_shared_ai_platform_client),
|
||||
audit_log_service: AuditLogService = Depends(get_audit_log_service),
|
||||
current_user: dict = Depends(require_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
parsed_tags = _parse_tags_json(tags_json)
|
||||
content = await file.read() if file is not None else None
|
||||
result = shared_ai_platform_client.retry_document(
|
||||
current_user=current_user,
|
||||
@@ -126,6 +290,18 @@ async def retry_knowledge_base_document(
|
||||
mime_type=file.content_type if file is not None else None,
|
||||
content=content,
|
||||
)
|
||||
result = _upsert_local_document_overlay(
|
||||
db,
|
||||
current_user=current_user,
|
||||
remote_item=result,
|
||||
metadata_override=_build_metadata_override(
|
||||
source_type=source_type,
|
||||
authority=authority,
|
||||
effective_date=effective_date,
|
||||
region=region,
|
||||
tags=parsed_tags,
|
||||
),
|
||||
)
|
||||
audit_log_service.record_event(
|
||||
tenant_id=current_user.get("tenant_id"),
|
||||
user_id=current_user.get("id"),
|
||||
@@ -145,6 +321,11 @@ async def retry_knowledge_base_document(
|
||||
"error_message": result.get("error_message"),
|
||||
"replacement_file": file is not None,
|
||||
"note": note,
|
||||
"source_type": result.get("source_type"),
|
||||
"authority": result.get("authority"),
|
||||
"effective_date": result.get("effective_date"),
|
||||
"region": result.get("region"),
|
||||
"tags": result.get("tags"),
|
||||
},
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
|
||||
@@ -52,6 +52,16 @@ class KnowledgeBaseDocumentRepository:
|
||||
query = query.filter(KnowledgeBaseDocument.knowledge_base_id == knowledge_base_id)
|
||||
return query.first()
|
||||
|
||||
def list_by_ids(self, tenant_id: str, document_ids: list[str]) -> List[KnowledgeBaseDocument]:
|
||||
if not document_ids:
|
||||
return []
|
||||
return (
|
||||
self.db.query(KnowledgeBaseDocument)
|
||||
.filter(KnowledgeBaseDocument.tenant_id == tenant_id)
|
||||
.filter(KnowledgeBaseDocument.id.in_(document_ids))
|
||||
.all()
|
||||
)
|
||||
|
||||
def count_by_knowledge_base_ids(self, tenant_id: str, knowledge_base_ids: list[str]) -> dict[str, int]:
|
||||
if not knowledge_base_ids:
|
||||
return {}
|
||||
|
||||
@@ -2,8 +2,12 @@ import unittest
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.api.knowledge_bases import get_audit_log_service, get_shared_ai_platform_client, require_current_user
|
||||
from app.db import Base, get_db_session
|
||||
from app.main import app
|
||||
|
||||
|
||||
@@ -167,12 +171,31 @@ class StubKnowledgeBaseService:
|
||||
class KnowledgeBasesApiTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.audit_log_service = StubAuditLogService()
|
||||
self.engine = create_engine(
|
||||
"sqlite://",
|
||||
future=True,
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
self.testing_session_local = sessionmaker(autocommit=False, autoflush=False, bind=self.engine)
|
||||
Base.metadata.create_all(bind=self.engine)
|
||||
|
||||
def override_db():
|
||||
db = self.testing_session_local()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db_session] = override_db
|
||||
app.dependency_overrides[get_shared_ai_platform_client] = lambda: StubKnowledgeBaseService()
|
||||
app.dependency_overrides[get_audit_log_service] = lambda: self.audit_log_service
|
||||
self.client = TestClient(app)
|
||||
|
||||
def tearDown(self):
|
||||
app.dependency_overrides.clear()
|
||||
Base.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def test_admin_can_list_knowledge_bases(self):
|
||||
app.dependency_overrides[require_current_user] = lambda: {
|
||||
@@ -262,6 +285,11 @@ class KnowledgeBasesApiTestCase(unittest.TestCase):
|
||||
"index_status": "completed",
|
||||
"error_message": None,
|
||||
"note": "首批规则",
|
||||
"source_type": None,
|
||||
"authority": None,
|
||||
"effective_date": None,
|
||||
"region": None,
|
||||
"tags": None,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -298,9 +326,61 @@ class KnowledgeBasesApiTestCase(unittest.TestCase):
|
||||
"error_message": None,
|
||||
"replacement_file": True,
|
||||
"note": "替换为空文档",
|
||||
"source_type": None,
|
||||
"authority": None,
|
||||
"effective_date": None,
|
||||
"region": None,
|
||||
"tags": None,
|
||||
},
|
||||
)
|
||||
|
||||
def test_uploaded_metadata_is_persisted_and_list_overlay_returns_it(self):
|
||||
app.dependency_overrides[require_current_user] = lambda: {
|
||||
"id": "user-admin",
|
||||
"tenant_id": "tenant-demo-001",
|
||||
"role": "admin",
|
||||
}
|
||||
|
||||
upload_response = self.client.post(
|
||||
"/api/v1/knowledge-bases/kb-123/documents",
|
||||
data={
|
||||
"note": "带法源元数据",
|
||||
"source_type": "法律",
|
||||
"authority": "全国人民代表大会",
|
||||
"effective_date": "2021-01-01",
|
||||
"region": "CN",
|
||||
"tags_json": '["民法典","合同"]',
|
||||
},
|
||||
files={"file": ("rules.txt", "合同解除规则".encode("utf-8"), "text/plain")},
|
||||
)
|
||||
|
||||
self.assertEqual(upload_response.status_code, 200)
|
||||
|
||||
listed = self.client.get("/api/v1/knowledge-bases/kb-123/documents")
|
||||
self.assertEqual(listed.status_code, 200)
|
||||
item = listed.json()["data"]["items"][0]
|
||||
self.assertEqual(item["source_type"], "法律")
|
||||
self.assertEqual(item["authority"], "全国人民代表大会")
|
||||
self.assertEqual(item["effective_date"], "2021-01-01")
|
||||
self.assertEqual(item["region"], "CN")
|
||||
self.assertEqual(item["tags"], ["民法典", "合同"])
|
||||
|
||||
def test_invalid_tags_json_is_rejected(self):
|
||||
app.dependency_overrides[require_current_user] = lambda: {
|
||||
"id": "user-admin",
|
||||
"tenant_id": "tenant-demo-001",
|
||||
"role": "admin",
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
"/api/v1/knowledge-bases/kb-123/documents",
|
||||
data={"tags_json": '{"bad":"shape"}'},
|
||||
files={"file": ("rules.txt", "abc".encode("utf-8"), "text/plain")},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["message"], "tags_json must be a JSON array of strings")
|
||||
|
||||
def test_member_cannot_access_knowledge_bases(self):
|
||||
app.dependency_overrides[require_current_user] = lambda: {
|
||||
"id": "user-member",
|
||||
|
||||
@@ -17,6 +17,11 @@ type KnowledgeBaseFormValues = {
|
||||
name: string;
|
||||
description?: string;
|
||||
note?: string;
|
||||
source_type?: string;
|
||||
authority?: string;
|
||||
effective_date?: string;
|
||||
region?: string;
|
||||
tags?: string;
|
||||
};
|
||||
|
||||
const DOCUMENT_STATUS_META: Record<string, { color: string; label: string }> = {
|
||||
@@ -361,7 +366,17 @@ export default function KnowledgeBasesPage() {
|
||||
setUploading(true);
|
||||
try {
|
||||
const note = uploadForm.getFieldValue("note")?.trim();
|
||||
const result = await uploadKnowledgeBaseDocument(selectedId, selectedFile, note);
|
||||
const metadata = {
|
||||
source_type: uploadForm.getFieldValue("source_type")?.trim() || undefined,
|
||||
authority: uploadForm.getFieldValue("authority")?.trim() || undefined,
|
||||
effective_date: uploadForm.getFieldValue("effective_date")?.trim() || undefined,
|
||||
region: uploadForm.getFieldValue("region")?.trim() || undefined,
|
||||
tags: ((uploadForm.getFieldValue("tags") as string | undefined) || "")
|
||||
.split(/[;,,;]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
};
|
||||
const result = await uploadKnowledgeBaseDocument(selectedId, selectedFile, note, metadata);
|
||||
if (result.code !== 0) {
|
||||
messageApi.error(result.message || "上传文档失败");
|
||||
return;
|
||||
@@ -372,7 +387,7 @@ export default function KnowledgeBasesPage() {
|
||||
: result.data.error_message || "文档上传完成,但当前未能完成入库",
|
||||
);
|
||||
setFileList([]);
|
||||
uploadForm.resetFields(["note"]);
|
||||
uploadForm.resetFields(["note", "source_type", "authority", "effective_date", "region", "tags"]);
|
||||
await Promise.all([loadKnowledgeBases(), loadDocuments(selectedId)]);
|
||||
} catch {
|
||||
messageApi.error("上传文档失败,请稍后重试");
|
||||
@@ -520,6 +535,21 @@ export default function KnowledgeBasesPage() {
|
||||
<Form.Item label="上传备注" name="note">
|
||||
<Input placeholder="例如:2026 版采购制度" />
|
||||
</Form.Item>
|
||||
<Form.Item label="法源类型" name="source_type">
|
||||
<Input placeholder="例如:法律 / 司法解释 / 制度 / policy" />
|
||||
</Form.Item>
|
||||
<Form.Item label="发文机关" name="authority">
|
||||
<Input placeholder="例如:全国人民代表大会 / 企业法务部" />
|
||||
</Form.Item>
|
||||
<Form.Item label="生效日期" name="effective_date">
|
||||
<Input placeholder="例如:2021-01-01" />
|
||||
</Form.Item>
|
||||
<Form.Item label="适用区域" name="region">
|
||||
<Input placeholder="例如:CN / CN-SH" />
|
||||
</Form.Item>
|
||||
<Form.Item label="标签" name="tags">
|
||||
<Input placeholder="用逗号或分号分隔,例如:民法典, 合同, 劳动用工" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Button type="primary" onClick={() => void handleUpload()} loading={uploading} disabled={!selectedId}>
|
||||
上传到当前知识库
|
||||
|
||||
@@ -38,18 +38,59 @@ export async function listKnowledgeBaseDocuments(knowledgeBaseId: string, page =
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function uploadKnowledgeBaseDocument(knowledgeBaseId: string, file: File, note?: string) {
|
||||
export type KnowledgeDocumentMetadataPayload = {
|
||||
source_type?: string;
|
||||
authority?: string;
|
||||
effective_date?: string;
|
||||
region?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
function appendMetadata(formData: FormData, metadata?: KnowledgeDocumentMetadataPayload) {
|
||||
if (!metadata) {
|
||||
return;
|
||||
}
|
||||
if (metadata.source_type) {
|
||||
formData.append("source_type", metadata.source_type);
|
||||
}
|
||||
if (metadata.authority) {
|
||||
formData.append("authority", metadata.authority);
|
||||
}
|
||||
if (metadata.effective_date) {
|
||||
formData.append("effective_date", metadata.effective_date);
|
||||
}
|
||||
if (metadata.region) {
|
||||
formData.append("region", metadata.region);
|
||||
}
|
||||
if (metadata.tags && metadata.tags.length > 0) {
|
||||
formData.append("tags_json", JSON.stringify(metadata.tags));
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadKnowledgeBaseDocument(
|
||||
knowledgeBaseId: string,
|
||||
file: File,
|
||||
note?: string,
|
||||
metadata?: KnowledgeDocumentMetadataPayload,
|
||||
) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
if (note) {
|
||||
formData.append("note", note);
|
||||
}
|
||||
appendMetadata(formData, metadata);
|
||||
|
||||
const response = await http.post<ApiResponse<KnowledgeBaseDocumentItem>>(`/knowledge-bases/${knowledgeBaseId}/documents`, formData);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function retryKnowledgeBaseDocument(knowledgeBaseId: string, documentId: string, file?: File, note?: string) {
|
||||
export async function retryKnowledgeBaseDocument(
|
||||
knowledgeBaseId: string,
|
||||
documentId: string,
|
||||
file?: File,
|
||||
note?: string,
|
||||
metadata?: KnowledgeDocumentMetadataPayload,
|
||||
) {
|
||||
const formData = new FormData();
|
||||
if (file) {
|
||||
formData.append("file", file);
|
||||
@@ -57,6 +98,7 @@ export async function retryKnowledgeBaseDocument(knowledgeBaseId: string, docume
|
||||
if (note) {
|
||||
formData.append("note", note);
|
||||
}
|
||||
appendMetadata(formData, metadata);
|
||||
|
||||
const response = await http.post<ApiResponse<KnowledgeBaseDocumentItem>>(
|
||||
`/knowledge-bases/${knowledgeBaseId}/documents/${documentId}/retry`,
|
||||
|
||||
@@ -1118,7 +1118,7 @@
|
||||
- 运营复核和最终批准目前仍主要由 admin 侧承接
|
||||
- 正式生产放行仍继续阻塞在真人复核、证据 READY、最终批准签署,以及知识库发布链的 shared-ai 远端能力缺口
|
||||
|
||||
## 2026-05-13 GOV-DB-007 知识库法源元数据第一层收口
|
||||
## 2026-05-13 GOV-DB-007 知识库法源元数据完整闭环完成
|
||||
|
||||
### 本轮完成项
|
||||
- 已为本地知识库文档表补齐法源元数据字段:
|
||||
@@ -1141,28 +1141,38 @@
|
||||
- 已补齐测试桩与兼容性验证:
|
||||
- `backend/tests/test_knowledge_bases_api.py`
|
||||
- `backend/tests/test_shared_ai_platform_client.py`
|
||||
- 已补齐本地可写入闭环:
|
||||
- `backend/app/api/knowledge_bases.py` 现在支持在上传 / 重试知识库文档时一并提交 `source_type / authority / effective_date / region / tags`
|
||||
- 本地 `knowledge_base_documents` 表会持久化这些法源元数据,并在知识库文档列表接口中覆盖回显
|
||||
- `scripts/import-phase13-library.ps1` 现在会根据 `legal-source-inventory.csv` 自动推导知识卡的法源类型、发文机关、生效日期、适用区域和标签,并随导入一起写入系统
|
||||
- 已补齐管理员录入口:
|
||||
- `frontend/src/app/knowledge-bases/page.tsx` 的上传表单新增法源类型、发文机关、生效日期、适用区域、标签输入项
|
||||
|
||||
### 本轮验证
|
||||
- 后端测试:
|
||||
- `python -m unittest backend.tests.test_knowledge_bases_api backend.tests.test_shared_ai_platform_client`
|
||||
- 结果:通过
|
||||
- `python -m unittest tests.test_knowledge_bases_api tests.test_shared_ai_platform_client`
|
||||
- 结果:`11 tests OK`
|
||||
- 后端编译:
|
||||
- `python -m py_compile backend/app/models/knowledge_base_document.py backend/app/clients/shared_ai_platform.py`
|
||||
- `python -m py_compile app/api/knowledge_bases.py app/repositories/knowledge_base_document_repository.py app/clients/shared_ai_platform.py app/models/knowledge_base_document.py`
|
||||
- 结果:通过
|
||||
- 前端检查:
|
||||
- `npm --prefix frontend run lint`
|
||||
- 结果:通过
|
||||
- `npm --prefix frontend run build`
|
||||
- 结果:通过,知识库页面构建正常
|
||||
- 脚本语法检查:
|
||||
- `import-phase13-library.ps1`
|
||||
- 结果:`POWERSHELL_PARSE_OK`
|
||||
|
||||
### 当前状态更新
|
||||
- `GOV-DB-007` 已从 `PLANNED` 推进到 `IN_PROGRESS`
|
||||
- `ai-contract-platform` 本仓库侧已经形成第一层闭环:
|
||||
- 本地表结构已具备法源元数据字段
|
||||
- shared-ai 返回的知识库文档数据已可在本仓库侧平铺、展示和过滤
|
||||
- 管理员已可在知识库页面按法源维度治理和筛查文档
|
||||
- `GOV-DB-007` 已从 `PLANNED` 推进到 `COMPLETED`
|
||||
- `ai-contract-platform` 本仓库侧已经形成完整闭环:
|
||||
- 本地表结构具备法源元数据字段
|
||||
- shared-ai 返回的知识库文档数据可在本仓库侧平铺、展示和过滤
|
||||
- 管理员可在知识库页面手动录入法源元数据
|
||||
- phase13 知识卡导入脚本可自动根据法源清单补齐这些元数据并落库
|
||||
- 知识库列表接口会把本地持久化元数据覆盖回显给前端
|
||||
|
||||
### 当前阻塞项
|
||||
- `GOV-DB-007` 仍未宣告 `COMPLETED`,原因不是本仓库缺页面或字段,而是 shared-ai 远端当前还没有正式生产化的知识库法源元数据写入链:
|
||||
- 远端知识库文档真实数据是否持续提供 `source_type / authority / effective_date / region / tags`,仍依赖 shared-ai 知识库导入/同步链配合
|
||||
- 当 shared-ai 远端稳定产出这些字段后,本项即可自然收口为 `COMPLETED`
|
||||
- 本项在 `ai-contract-platform` 仓库内已无代码级阻塞。
|
||||
- 后续 shared-ai 若继续补齐远端原生知识库元数据写入链,会是增强项,而不再阻塞本仓库的 `GOV-DB-007` 闭环。
|
||||
|
||||
@@ -5,7 +5,7 @@ batch,priority,area,task_id,title,current_gap,target_deliverable,depends_on,acce
|
||||
5A,P0,database,GOV-DB-004,新增证据登记表,当前证据路径和证据状态只存在文档与脚本规则中,新增 governance_evidences 表支持 file_id url status checksum reviewer metadata,GOV-DB-001,系统可校验证据是否存在且不是模板占位,COMPLETED
|
||||
5A,P0,database,GOV-DB-005,新增发布批次与门禁结果表,当前上线批准状态只存在 PowerShell 产物中,新增 governance_release_batches 和 governance_gate_runs 表,GOV-DB-001;GOV-DB-003;GOV-DB-004,系统可追踪一次发布批次的门禁快照和结果,COMPLETED
|
||||
5A,P1,database,GOV-DB-006,模板表补治理字段,contract_templates 只有内容和启停没有版本与发布元数据,为 contract_templates 增加 template_code version published_at published_by latest_governance_asset_id,GOV-DB-001,模板可区分草稿版和生产发布版,COMPLETED
|
||||
5A,P1,database,GOV-DB-007,知识库文档表补法源元数据,知识库文档缺少效力级别生效日期发文机关和法源类型,为知识库相关表增加 source_type authority effective_date region tags,none,知识库可按法源维度治理和过滤,IN_PROGRESS
|
||||
5A,P1,database,GOV-DB-007,知识库文档表补法源元数据,知识库文档缺少效力级别生效日期发文机关和法源类型,为知识库相关表增加 source_type authority effective_date region tags,none,知识库可按法源维度治理和过滤,COMPLETED
|
||||
5B,P0,backend_api,GOV-API-001,新增治理资产列表接口,当前没有统一治理视图接口,新增 /governance/assets 列表筛选汇总接口,GOV-DB-001,前端可按资产类型状态角色查看治理资产,COMPLETED
|
||||
5B,P0,backend_api,GOV-API-002,新增复核分配接口,当前无法在系统内分配真实复核人,新增 /governance/assignments 创建更新撤销接口,GOV-DB-002,管理员可在系统内完成复核任务分配,COMPLETED
|
||||
5B,P0,backend_api,GOV-API-003,新增复核回填接口,当前复核结论只能通过CSV和脚本回填,新增 /governance/decisions 提交接口和批量导入接口,GOV-DB-003,真实复核人或管理员可提交复核结果,COMPLETED
|
||||
|
||||
|
@@ -302,13 +302,19 @@ function Import-KnowledgeCard {
|
||||
[string]$KnowledgeBaseId,
|
||||
[object]$Card,
|
||||
[string]$AbsolutePath,
|
||||
[object[]]$ExistingDocuments
|
||||
[object[]]$ExistingDocuments,
|
||||
[hashtable]$SourceMetadataLookup
|
||||
)
|
||||
|
||||
$fileName = "$($Card.knowledge_card_id).md"
|
||||
$existing = @($ExistingDocuments | Where-Object { $_.file_name -eq $fileName }) | Select-Object -First 1
|
||||
$bytes = [System.IO.File]::ReadAllBytes($AbsolutePath)
|
||||
$note = "阶段13知识卡导入;编号=$($Card.knowledge_card_id);状态=$($Card.status);法源=$($Card.source_ids)。"
|
||||
$fields = @{ note = $note }
|
||||
$metadataFields = Get-KnowledgeCardMetadataFields -Card $Card -SourceMetadataLookup $SourceMetadataLookup
|
||||
foreach ($fieldName in $metadataFields.Keys) {
|
||||
$fields[$fieldName] = $metadataFields[$fieldName]
|
||||
}
|
||||
|
||||
if ($existing -and -not $ForceReupload) {
|
||||
return [ordered]@{
|
||||
@@ -322,9 +328,7 @@ function Import-KnowledgeCard {
|
||||
}
|
||||
|
||||
if ($existing -and $ForceReupload) {
|
||||
$response = Invoke-FormRequest -Method "POST" -Url "$ApiBaseUrl/knowledge-bases/$KnowledgeBaseId/documents/$($existing.id)/retry" -Headers $Headers -Fields @{
|
||||
note = $note
|
||||
} -FileField "file" -FileName $fileName -FileContent $bytes
|
||||
$response = Invoke-FormRequest -Method "POST" -Url "$ApiBaseUrl/knowledge-bases/$KnowledgeBaseId/documents/$($existing.id)/retry" -Headers $Headers -Fields $fields -FileField "file" -FileName $fileName -FileContent $bytes
|
||||
$data = Get-ResponseData -Response $response
|
||||
return [ordered]@{
|
||||
asset_type = "knowledge_card"
|
||||
@@ -336,9 +340,7 @@ function Import-KnowledgeCard {
|
||||
}
|
||||
}
|
||||
|
||||
$response = Invoke-FormRequest -Method "POST" -Url "$ApiBaseUrl/knowledge-bases/$KnowledgeBaseId/documents" -Headers $Headers -Fields @{
|
||||
note = $note
|
||||
} -FileField "file" -FileName $fileName -FileContent $bytes
|
||||
$response = Invoke-FormRequest -Method "POST" -Url "$ApiBaseUrl/knowledge-bases/$KnowledgeBaseId/documents" -Headers $Headers -Fields $fields -FileField "file" -FileName $fileName -FileContent $bytes
|
||||
$data = Get-ResponseData -Response $response
|
||||
return [ordered]@{
|
||||
asset_type = "knowledge_card"
|
||||
@@ -350,6 +352,54 @@ function Import-KnowledgeCard {
|
||||
}
|
||||
}
|
||||
|
||||
function Get-UniqueValues {
|
||||
param([object[]]$Values)
|
||||
return @($Values | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | ForEach-Object { ([string]$_).Trim() } | Select-Object -Unique)
|
||||
}
|
||||
|
||||
function Get-KnowledgeCardMetadataFields {
|
||||
param(
|
||||
[object]$Card,
|
||||
[hashtable]$SourceMetadataLookup
|
||||
)
|
||||
|
||||
$sourceIds = @($Card.source_ids -split ";" | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
$sources = @()
|
||||
foreach ($sourceId in $sourceIds) {
|
||||
if ($SourceMetadataLookup.ContainsKey($sourceId)) {
|
||||
$sources += $SourceMetadataLookup[$sourceId]
|
||||
}
|
||||
}
|
||||
|
||||
$sourceTypes = Get-UniqueValues -Values @($sources | ForEach-Object { $_.source_type })
|
||||
$authorities = Get-UniqueValues -Values @($sources | ForEach-Object { $_.issuer })
|
||||
$effectiveDates = Get-UniqueValues -Values @($sources | ForEach-Object { $_.effective_date } | Where-Object { $_ -and $_ -ne "VERIFY_REQUIRED" })
|
||||
$domains = Get-UniqueValues -Values @($sources | ForEach-Object { $_.knowledge_domain })
|
||||
|
||||
$tags = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($value in @($sourceIds + $domains)) {
|
||||
if (-not [string]::IsNullOrWhiteSpace($value) -and -not $tags.Contains($value)) {
|
||||
$tags.Add($value) | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
$fields = @{}
|
||||
if ($sourceTypes.Count -gt 0) {
|
||||
$fields["source_type"] = if ($sourceTypes.Count -eq 1) { $sourceTypes[0] } else { ($sourceTypes -join ";") }
|
||||
}
|
||||
if ($authorities.Count -gt 0) {
|
||||
$fields["authority"] = if ($authorities.Count -eq 1) { $authorities[0] } else { ($authorities -join ";") }
|
||||
}
|
||||
if ($effectiveDates.Count -gt 0) {
|
||||
$fields["effective_date"] = if ($effectiveDates.Count -eq 1) { $effectiveDates[0] } else { ($effectiveDates -join ";") }
|
||||
}
|
||||
$fields["region"] = "CN"
|
||||
if ($tags.Count -gt 0) {
|
||||
$fields["tags_json"] = ($tags.ToArray() | ConvertTo-Json -Compress)
|
||||
}
|
||||
return $fields
|
||||
}
|
||||
|
||||
function Get-TemplateVariablesJson {
|
||||
param([string]$VariableSchemaPath)
|
||||
if ([string]::IsNullOrWhiteSpace($VariableSchemaPath)) {
|
||||
@@ -451,9 +501,11 @@ $kbQaSampleInventoryPath = Join-Path $LibraryRoot "inventories\kb-qa-golden-samp
|
||||
|
||||
$sourceRows = Import-CsvRows -Path $legalSourceInventoryPath
|
||||
$sourceIdSet = @{}
|
||||
$sourceMetadataLookup = @{}
|
||||
foreach ($source in $sourceRows) {
|
||||
if (-not [string]::IsNullOrWhiteSpace($source.source_id)) {
|
||||
$sourceIdSet[$source.source_id] = $true
|
||||
$sourceMetadataLookup[$source.source_id] = $source
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,7 +594,7 @@ if ($Mode -ne "Validate" -and $validationFailures -eq 0) {
|
||||
foreach ($card in $eligibleKnowledgeCards) {
|
||||
$absolutePath = Resolve-LibraryPath -RelativePath $card.summary_path
|
||||
try {
|
||||
$result = Import-KnowledgeCard -Headers $headers -KnowledgeBaseId $targetKnowledgeBase.id -Card $card -AbsolutePath $absolutePath -ExistingDocuments $existingDocs
|
||||
$result = Import-KnowledgeCard -Headers $headers -KnowledgeBaseId $targetKnowledgeBase.id -Card $card -AbsolutePath $absolutePath -ExistingDocuments $existingDocs -SourceMetadataLookup $sourceMetadataLookup
|
||||
$importReport.Add($result) | Out-Null
|
||||
} catch {
|
||||
$importReport.Add([ordered]@{
|
||||
|
||||
Reference in New Issue
Block a user