feat: complete knowledge source metadata loop

This commit is contained in:
2026-05-14 00:17:09 +08:00
parent f71b830683
commit 5fd02c6ba0
8 changed files with 433 additions and 28 deletions
+184 -3
View File
@@ -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 {}
+80
View File
@@ -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",
+32 -2
View File
@@ -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}>
上传到当前知识库
+44 -2
View File
@@ -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
1 batch priority area task_id title current_gap target_deliverable depends_on acceptance status
5 5A P0 database GOV-DB-004 新增证据登记表 当前证据路径和证据状态只存在文档与脚本规则中 新增 governance_evidences 表支持 file_id url status checksum reviewer metadata GOV-DB-001 系统可校验证据是否存在且不是模板占位 COMPLETED
6 5A P0 database GOV-DB-005 新增发布批次与门禁结果表 当前上线批准状态只存在 PowerShell 产物中 新增 governance_release_batches 和 governance_gate_runs 表 GOV-DB-001;GOV-DB-003;GOV-DB-004 系统可追踪一次发布批次的门禁快照和结果 COMPLETED
7 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
8 5A P1 database GOV-DB-007 知识库文档表补法源元数据 知识库文档缺少效力级别生效日期发文机关和法源类型 为知识库相关表增加 source_type authority effective_date region tags none 知识库可按法源维度治理和过滤 IN_PROGRESS COMPLETED
9 5B P0 backend_api GOV-API-001 新增治理资产列表接口 当前没有统一治理视图接口 新增 /governance/assets 列表筛选汇总接口 GOV-DB-001 前端可按资产类型状态角色查看治理资产 COMPLETED
10 5B P0 backend_api GOV-API-002 新增复核分配接口 当前无法在系统内分配真实复核人 新增 /governance/assignments 创建更新撤销接口 GOV-DB-002 管理员可在系统内完成复核任务分配 COMPLETED
11 5B P0 backend_api GOV-API-003 新增复核回填接口 当前复核结论只能通过CSV和脚本回填 新增 /governance/decisions 提交接口和批量导入接口 GOV-DB-003 真实复核人或管理员可提交复核结果 COMPLETED
+60 -8
View File
@@ -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]@{