Files
ljwx-deploy/scripts/knowledge/common.py
T

935 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Shared helpers for the knowledge supply chain MVP."""
from __future__ import annotations
import fnmatch
import hashlib
import json
import re
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Final, cast
try:
import jsonschema
except ImportError as exc:
raise SystemExit(
f"缺少依赖 jsonschema,请使用 uvx --with jsonschema --with pyyaml python <script>\n{exc}"
) from exc
try:
import yaml
except ImportError as exc:
raise SystemExit(
f"缺少依赖 PyYAML,请使用 uvx --with pyyaml --with jsonschema python <script>\n{exc}"
) from exc
JSONMapping = dict[str, object]
SOURCE_CONFIG_SCHEMA_RELATIVE: Final[Path] = Path(
"platform/contracts/knowledge-source-config.schema.yaml"
)
REVIEW_LEDGER_SCHEMA_RELATIVE: Final[Path] = Path(
"platform/contracts/knowledge-review-ledger.schema.yaml"
)
ARTIFACT_SCHEMA_RELATIVE: Final[Path] = Path(
"platform/contracts/knowledge-artifact.schema.yaml"
)
PUBLISH_RECORD_SCHEMA_RELATIVE: Final[Path] = Path(
"platform/contracts/knowledge-publish-record.schema.yaml"
)
DOCUMENT_SCHEMA_RELATIVE: Final[Path] = Path(
"platform/contracts/knowledge-document.schema.yaml"
)
CHUNK_SCHEMA_RELATIVE: Final[Path] = Path(
"platform/contracts/knowledge-chunk.schema.yaml"
)
SOURCE_REGISTRY_RELATIVE: Final[Path] = Path("platform/knowledge/source-registry.yaml")
ACL_POLICIES_RELATIVE: Final[Path] = Path("platform/knowledge/acl-policies.yaml")
PUBLISH_TARGETS_RELATIVE: Final[Path] = Path("platform/knowledge/publish-targets.yaml")
DEFAULT_MAX_CHUNK_CHARACTERS: Final[int] = 1200
DEFAULT_REVIEW_STANDARDS_VERSION: Final[str] = "knowledge-mvp-1"
MARKDOWN_MIN_BODY_CHARACTERS: Final[int] = 80
MARKDOWN_MAX_TITLE_CHARACTERS: Final[int] = 120
PLACEHOLDER_PATTERN: Final[re.Pattern[str]] = re.compile(
r"\b(todo|tbd|placeholder|coming soon)\b|待补充|待完善|占位",
re.IGNORECASE,
)
class KnowledgeContractError(Exception):
"""Raised when a knowledge contract is invalid."""
@dataclass(frozen=True, slots=True)
class ChangeEntry:
"""Represents one git diff path change."""
change_type: str
path: str
@dataclass(frozen=True, slots=True)
class SourceRule:
"""Represents one source rule declared by a business repository."""
source_id: str
include: tuple[str, ...]
exclude: tuple[str, ...]
visibility: str
source_type: str
doc_type: str
product_area: str
owner: str
review_status: str
tags: tuple[str, ...]
title_strategy: str
title_prefix: str
review_mode: str
approved_by: str | None
effective_from: str | None
expires_at: str | None
@dataclass(frozen=True, slots=True)
class ReviewEntry:
"""Represents one document-level review ledger entry."""
path: str
source_id: str
checksum: str
review_status: str
approved_by: str | None
effective_from: str | None
expires_at: str | None
standards_version: str
review_notes: str | None
@dataclass(frozen=True, slots=True)
class ResolvedReview:
"""Represents effective review metadata for one document."""
review_status: str
approved_by: str | None
effective_from: str | None
expires_at: str | None
checksum: str
standards_version: str
review_notes: str | None
standard_issues: tuple[str, ...]
review_issues: tuple[str, ...]
reason: str | None
@dataclass(frozen=True, slots=True)
class KnowledgePaths:
"""Filesystem layout inside ljwx-deploy for knowledge state."""
artifacts_root: Path
events_root: Path
publish_records_root: Path
def utc_now() -> str:
"""Return an ISO-8601 timestamp in UTC."""
return datetime.now(tz=UTC).isoformat()
def ensure_directory(path: Path) -> None:
"""Create a directory tree when it does not exist."""
path.mkdir(parents=True, exist_ok=True)
def ensure_parent(path: Path) -> None:
"""Create the parent directory for a file path."""
ensure_directory(path.parent)
def write_json(path: Path, payload: JSONMapping | list[JSONMapping]) -> None:
"""Write a JSON file with deterministic formatting."""
ensure_parent(path)
path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
def load_json_mapping(path: Path) -> JSONMapping:
"""Load a JSON object from disk."""
raw = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise KnowledgeContractError(f"{path} 根节点必须是 object")
return {str(key): value for key, value in raw.items()}
def load_yaml_mapping(path: Path) -> JSONMapping:
"""Load a YAML mapping from disk."""
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
if raw is None:
return {}
if not isinstance(raw, dict):
raise KnowledgeContractError(f"{path} 根节点必须是 mapping")
return {str(key): value for key, value in raw.items()}
def write_jsonl(path: Path, rows: list[JSONMapping]) -> None:
"""Write JSONL rows to disk."""
ensure_parent(path)
with path.open("w", encoding="utf-8") as handle:
for row in rows:
handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True))
handle.write("\n")
def append_jsonl(path: Path, row: JSONMapping) -> None:
"""Append one JSONL row to disk."""
ensure_parent(path)
with path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True))
handle.write("\n")
def load_jsonl(path: Path) -> list[JSONMapping]:
"""Load JSONL records from disk when present."""
if not path.exists():
return []
rows: list[JSONMapping] = []
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped:
continue
raw = json.loads(stripped)
if not isinstance(raw, dict):
raise KnowledgeContractError(f"{path} 存在非 mapping JSONL 记录")
rows.append({str(key): value for key, value in raw.items()})
return rows
def load_validator(path: Path) -> jsonschema.Draft202012Validator:
"""Load a JSON schema validator from YAML."""
schema = load_yaml_mapping(path)
jsonschema.Draft202012Validator.check_schema(schema)
return jsonschema.Draft202012Validator(schema)
def validate_payload(
validator: jsonschema.Draft202012Validator,
payload: JSONMapping,
payload_name: str,
) -> None:
"""Validate one payload against a schema."""
try:
validator.validate(payload)
except jsonschema.ValidationError as exc:
raise KnowledgeContractError(
f"{payload_name} 不符合 schema: {exc.message}"
) from exc
def require_mapping(value: object, field_path: str) -> JSONMapping:
"""Require a JSON mapping."""
if not isinstance(value, dict):
raise KnowledgeContractError(f"{field_path} 必须是 mapping")
return {str(key): item for key, item in value.items()}
def require_list(value: object, field_path: str) -> list[object]:
"""Require a list."""
if not isinstance(value, list):
raise KnowledgeContractError(f"{field_path} 必须是列表")
return value
def require_non_empty_str(value: object, field_path: str) -> str:
"""Require a non-empty string."""
if not isinstance(value, str) or not value.strip():
raise KnowledgeContractError(f"{field_path} 必须是非空字符串")
return value.strip()
def optional_non_empty_str(value: object, field_path: str) -> str | None:
"""Read an optional non-empty string."""
if value in ("", None):
return None
return require_non_empty_str(value, field_path)
def require_str_list(value: object, field_path: str) -> list[str]:
"""Require a list of strings."""
items = require_list(value, field_path)
results: list[str] = []
for index, item in enumerate(items):
results.append(require_non_empty_str(item, f"{field_path}[{index}]"))
return results
def sha256_text(text: str) -> str:
"""Return the SHA-256 digest of a text payload."""
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def control_files_changed(
diff_entries: list[ChangeEntry], controlled_paths: set[str]
) -> bool:
"""Return True when a diff touched control files that require full reevaluation."""
return any(entry.path in controlled_paths for entry in diff_entries)
def slugify_fragment(value: str) -> str:
"""Convert a string into a filesystem-safe slug."""
normalized = re.sub(r"[^a-zA-Z0-9]+", "-", value).strip("-").lower()
return normalized or "unknown"
def repo_slug(repo: str) -> str:
"""Return a stable directory slug for an owner/repo name."""
return repo.replace("/", "__")
def build_document_id(source_repo: str, source_path: str) -> str:
"""Build a deterministic document identifier from repo and path."""
repo_name = source_repo.split("/")[-1]
base_slug = slugify_fragment(f"{repo_name}-{source_path}")
if len(base_slug) <= 96:
return base_slug
digest = hashlib.sha1(f"{source_repo}:{source_path}".encode("utf-8")).hexdigest()[
:12
]
return f"{base_slug[:80]}-{digest}"
def match_globs(
path: str, includes: tuple[str, ...], excludes: tuple[str, ...]
) -> bool:
"""Return True when a path matches include/exclude rules."""
included = any(fnmatch.fnmatch(path, pattern) for pattern in includes)
if not included:
return False
return not any(fnmatch.fnmatch(path, pattern) for pattern in excludes)
def parse_diff_entries(path: Path) -> list[ChangeEntry]:
"""Parse git diff --name-status output into flattened path changes."""
if not path.exists():
return []
results: list[ChangeEntry] = []
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line:
continue
parts = line.split("\t")
status = require_non_empty_str(parts[0], "diff.status")
base_status = status[0]
if base_status in {"A", "C", "M", "T"}:
if len(parts) < 2:
raise KnowledgeContractError(f"无效 diff 行: {line}")
results.append(ChangeEntry(change_type="upsert", path=parts[-1]))
continue
if base_status == "D":
if len(parts) != 2:
raise KnowledgeContractError(f"无效删除 diff 行: {line}")
results.append(ChangeEntry(change_type="delete", path=parts[1]))
continue
if base_status == "R":
if len(parts) != 3:
raise KnowledgeContractError(f"无效重命名 diff 行: {line}")
results.append(ChangeEntry(change_type="delete", path=parts[1]))
results.append(ChangeEntry(change_type="upsert", path=parts[2]))
continue
raise KnowledgeContractError(f"暂不支持的 diff 状态: {status}")
return results
def extract_markdown_title(content: str, source_path: str, strategy: str) -> str:
"""Resolve a document title from markdown content."""
if strategy == "filename":
return Path(source_path).stem.replace("-", " ").replace("_", " ").strip()
for line in content.splitlines():
stripped = line.strip()
if stripped.startswith("# "):
return stripped.removeprefix("# ").strip()
return Path(source_path).stem.replace("-", " ").replace("_", " ").strip()
def chunk_markdown(
content: str, max_characters: int = DEFAULT_MAX_CHUNK_CHARACTERS
) -> list[str]:
"""Chunk markdown into paragraph groups."""
paragraphs = [item.strip() for item in content.split("\n\n") if item.strip()]
if not paragraphs:
return []
chunks: list[str] = []
current_parts: list[str] = []
current_length = 0
for paragraph in paragraphs:
paragraph_length = len(paragraph)
if current_parts and current_length + paragraph_length > max_characters:
chunks.append("\n\n".join(current_parts))
current_parts = [paragraph]
current_length = paragraph_length
continue
current_parts.append(paragraph)
current_length += paragraph_length
if current_parts:
chunks.append("\n\n".join(current_parts))
return chunks
def knowledge_paths(deploy_root: Path, environment: str) -> KnowledgePaths:
"""Resolve the filesystem layout used by central knowledge state."""
base_root = deploy_root / "knowledge"
return KnowledgePaths(
artifacts_root=base_root / "artifacts" / environment,
events_root=base_root / "events" / environment,
publish_records_root=base_root / "publish-records" / environment,
)
def load_source_rules(
config_path: Path, deploy_root: Path
) -> tuple[str, list[SourceRule]]:
"""Load and validate source config rules."""
validator = load_validator(deploy_root / SOURCE_CONFIG_SCHEMA_RELATIVE)
payload = load_yaml_mapping(config_path)
validate_payload(validator, payload, config_path.as_posix())
service = require_non_empty_str(
payload.get("service"), "knowledge.sources.yaml.service"
)
raw_sources = require_list(payload.get("sources"), "knowledge.sources.yaml.sources")
rules: list[SourceRule] = []
for index, raw_source in enumerate(raw_sources):
source = require_mapping(raw_source, f"knowledge.sources.yaml.sources[{index}]")
rules.append(
SourceRule(
source_id=require_non_empty_str(
source.get("id"), f"knowledge.sources.yaml.sources[{index}].id"
),
include=tuple(
require_str_list(
source.get("include"),
f"knowledge.sources.yaml.sources[{index}].include",
)
),
exclude=tuple(
require_str_list(
source.get("exclude", []),
f"knowledge.sources.yaml.sources[{index}].exclude",
)
),
visibility=require_non_empty_str(
source.get("visibility"),
f"knowledge.sources.yaml.sources[{index}].visibility",
),
source_type=require_non_empty_str(
source.get("source_type"),
f"knowledge.sources.yaml.sources[{index}].source_type",
),
doc_type=require_non_empty_str(
source.get("doc_type"),
f"knowledge.sources.yaml.sources[{index}].doc_type",
),
product_area=require_non_empty_str(
source.get("product_area"),
f"knowledge.sources.yaml.sources[{index}].product_area",
),
owner=require_non_empty_str(
source.get("owner"),
f"knowledge.sources.yaml.sources[{index}].owner",
),
review_status=require_non_empty_str(
source.get("review_status"),
f"knowledge.sources.yaml.sources[{index}].review_status",
),
tags=tuple(
require_str_list(
source.get("tags", []),
f"knowledge.sources.yaml.sources[{index}].tags",
)
),
title_strategy=optional_non_empty_str(
source.get("title_strategy"),
f"knowledge.sources.yaml.sources[{index}].title_strategy",
)
or "first_heading",
title_prefix=optional_non_empty_str(
source.get("title_prefix"),
f"knowledge.sources.yaml.sources[{index}].title_prefix",
)
or "",
review_mode=optional_non_empty_str(
source.get("review_mode"),
f"knowledge.sources.yaml.sources[{index}].review_mode",
)
or "legacy",
approved_by=optional_non_empty_str(
source.get("approved_by"),
f"knowledge.sources.yaml.sources[{index}].approved_by",
),
effective_from=optional_non_empty_str(
source.get("effective_from"),
f"knowledge.sources.yaml.sources[{index}].effective_from",
),
expires_at=optional_non_empty_str(
source.get("expires_at"),
f"knowledge.sources.yaml.sources[{index}].expires_at",
),
)
)
return service, rules
def load_review_entries(
review_path: Path, deploy_root: Path
) -> dict[tuple[str, str], ReviewEntry]:
"""Load an optional document-level review ledger."""
if not review_path.exists():
return {}
validator = load_validator(deploy_root / REVIEW_LEDGER_SCHEMA_RELATIVE)
payload = load_yaml_mapping(review_path)
validate_payload(validator, payload, review_path.as_posix())
raw_documents = require_list(
payload.get("documents", []),
f"{review_path.name}.documents",
)
entries: dict[tuple[str, str], ReviewEntry] = {}
for index, raw_document in enumerate(raw_documents):
document = require_mapping(
raw_document, f"{review_path.name}.documents[{index}]"
)
entry = ReviewEntry(
path=require_non_empty_str(
document.get("path"), f"{review_path.name}.documents[{index}].path"
),
source_id=require_non_empty_str(
document.get("source_id"),
f"{review_path.name}.documents[{index}].source_id",
),
checksum=require_non_empty_str(
document.get("checksum"),
f"{review_path.name}.documents[{index}].checksum",
),
review_status=require_non_empty_str(
document.get("review_status"),
f"{review_path.name}.documents[{index}].review_status",
),
approved_by=optional_non_empty_str(
document.get("approved_by"),
f"{review_path.name}.documents[{index}].approved_by",
),
effective_from=optional_non_empty_str(
document.get("effective_from"),
f"{review_path.name}.documents[{index}].effective_from",
),
expires_at=optional_non_empty_str(
document.get("expires_at"),
f"{review_path.name}.documents[{index}].expires_at",
),
standards_version=optional_non_empty_str(
document.get("standards_version"),
f"{review_path.name}.documents[{index}].standards_version",
)
or DEFAULT_REVIEW_STANDARDS_VERSION,
review_notes=optional_non_empty_str(
document.get("review_notes"),
f"{review_path.name}.documents[{index}].review_notes",
),
)
key = (entry.source_id, entry.path)
if key in entries:
raise KnowledgeContractError(
f"{review_path.name} 存在重复审核记录: {entry.source_id}/{entry.path}"
)
entries[key] = entry
return entries
def markdown_standard_issues(source_path: str, content: str) -> list[str]:
"""Validate the minimum markdown standard required by the knowledge workflow."""
issues: list[str] = []
stripped_content = content.strip()
if not stripped_content:
return ["文档内容为空"]
title = extract_markdown_title(content, source_path, "first_heading").strip()
if "# " not in content:
issues.append("缺少一级标题(# Heading)")
if len(title) < 3:
issues.append("标题过短")
if len(title) > MARKDOWN_MAX_TITLE_CHARACTERS:
issues.append("标题过长")
body_parts: list[str] = []
in_code_fence = False
for raw_line in content.splitlines():
stripped_line = raw_line.strip()
if stripped_line.startswith("```"):
in_code_fence = not in_code_fence
continue
if in_code_fence or not stripped_line:
continue
if stripped_line.startswith("#"):
continue
if re.fullmatch(r"[-_*`~ ]+", stripped_line):
continue
body_parts.append(stripped_line)
body_text = " ".join(body_parts).strip()
if len(body_text) < MARKDOWN_MIN_BODY_CHARACTERS:
issues.append("正文内容过短,未达到最小信息量要求")
if PLACEHOLDER_PATTERN.search(body_text) and len(body_text) < (
MARKDOWN_MIN_BODY_CHARACTERS * 2
):
issues.append("正文仍包含明显占位内容")
return issues
def resolve_document_review(
*,
rule: SourceRule,
relative_path: str,
content: str,
review_entries: dict[tuple[str, str], ReviewEntry],
) -> ResolvedReview:
"""Resolve effective review metadata for one document."""
checksum = sha256_text(content)
standard_issues = tuple(markdown_standard_issues(relative_path, content))
if rule.review_mode != "required":
return ResolvedReview(
review_status=rule.review_status,
approved_by=rule.approved_by,
effective_from=rule.effective_from,
expires_at=rule.expires_at,
checksum=checksum,
standards_version=DEFAULT_REVIEW_STANDARDS_VERSION,
review_notes=None,
standard_issues=standard_issues,
review_issues=(),
reason=None if not standard_issues else "legacy_standard_warning",
)
review_issues: list[str] = []
review_status = "reviewing"
approved_by: str | None = None
effective_from: str | None = None
expires_at: str | None = None
standards_version = DEFAULT_REVIEW_STANDARDS_VERSION
review_notes: str | None = None
entry = review_entries.get((rule.source_id, relative_path))
if entry is None:
review_issues.append("缺少 knowledge.review.yaml 审核记录")
else:
review_status = entry.review_status
approved_by = entry.approved_by
effective_from = entry.effective_from
expires_at = entry.expires_at
standards_version = entry.standards_version
review_notes = entry.review_notes
if entry.checksum != checksum:
review_issues.append("审核记录 checksum 与当前文档不一致")
if entry.review_status != "approved":
review_issues.append(f"审核状态不是 approved: {entry.review_status}")
if standard_issues:
review_status = "reviewing"
if review_issues and review_status == "approved":
review_status = "reviewing"
reason_parts = list(standard_issues) + review_issues
reason = "; ".join(reason_parts) if reason_parts else None
return ResolvedReview(
review_status=review_status,
approved_by=approved_by,
effective_from=effective_from,
expires_at=expires_at,
checksum=checksum,
standards_version=standards_version,
review_notes=review_notes,
standard_issues=standard_issues,
review_issues=tuple(review_issues),
reason=reason,
)
def load_source_registry(deploy_root: Path) -> dict[str, dict[str, object]]:
"""Load artifact source registry entries keyed by repository."""
payload = load_yaml_mapping(deploy_root / SOURCE_REGISTRY_RELATIVE)
raw_registry = require_list(
payload.get("artifact_sources", []),
"platform/knowledge/source-registry.yaml.artifact_sources",
)
registry: dict[str, dict[str, object]] = {}
for index, raw_entry in enumerate(raw_registry):
entry = require_mapping(
raw_entry,
f"platform/knowledge/source-registry.yaml.artifact_sources[{index}]",
)
repo_name = require_non_empty_str(
entry.get("repo"),
f"platform/knowledge/source-registry.yaml.artifact_sources[{index}].repo",
)
registry[repo_name] = {
"service": require_non_empty_str(
entry.get("service"),
"platform/knowledge/source-registry.yaml.artifact_sources."
f"[{index}].service",
),
"source_ids": require_str_list(
entry.get("source_ids", []),
"platform/knowledge/source-registry.yaml.artifact_sources."
f"[{index}].source_ids",
),
"allowed_visibilities": require_str_list(
entry.get("allowed_visibilities", []),
"platform/knowledge/source-registry.yaml.artifact_sources."
f"[{index}].allowed_visibilities",
),
}
return registry
def load_acl_policies(deploy_root: Path) -> dict[str, dict[str, object]]:
"""Load ACL policies keyed by visibility."""
payload = load_yaml_mapping(deploy_root / ACL_POLICIES_RELATIVE)
raw_policies = require_mapping(
payload.get("policies"),
"platform/knowledge/acl-policies.yaml.policies",
)
policies: dict[str, dict[str, object]] = {}
for visibility, raw_policy in raw_policies.items():
policy = require_mapping(
raw_policy,
f"platform/knowledge/acl-policies.yaml.policies.{visibility}",
)
policies[visibility] = {
"acl_expression_template": require_non_empty_str(
policy.get("acl_expression_template"),
"platform/knowledge/acl-policies.yaml.policies."
f"{visibility}.acl_expression_template",
),
"allowed_targets": require_str_list(
policy.get("allowed_targets", []),
"platform/knowledge/acl-policies.yaml.policies."
f"{visibility}.allowed_targets",
),
"review_required": bool(policy.get("review_required", False)),
}
return policies
def load_publish_targets(deploy_root: Path) -> dict[str, dict[str, object]]:
"""Load publish targets keyed by target id."""
payload = load_yaml_mapping(deploy_root / PUBLISH_TARGETS_RELATIVE)
raw_targets = require_mapping(
payload.get("targets"),
"platform/knowledge/publish-targets.yaml.targets",
)
targets: dict[str, dict[str, object]] = {}
for target_id, raw_target in raw_targets.items():
target = require_mapping(
raw_target, f"platform/knowledge/publish-targets.yaml.targets.{target_id}"
)
targets[target_id] = {
"type": require_non_empty_str(
target.get("type"),
f"platform/knowledge/publish-targets.yaml.targets.{target_id}.type",
),
"visibility": require_non_empty_str(
target.get("visibility"),
"platform/knowledge/publish-targets.yaml.targets."
f"{target_id}.visibility",
),
"dataset_name": optional_non_empty_str(
target.get("dataset_name"),
"platform/knowledge/publish-targets.yaml.targets."
f"{target_id}.dataset_name",
),
"indexing_technique": optional_non_empty_str(
target.get("indexing_technique"),
"platform/knowledge/publish-targets.yaml.targets."
f"{target_id}.indexing_technique",
),
}
return targets
def load_publish_record(path: Path) -> JSONMapping:
"""Load one publish record file."""
return load_json_mapping(path)
def load_publish_records(root: Path) -> dict[str, JSONMapping]:
"""Load all publish records under a directory."""
if not root.exists():
return {}
records: dict[str, JSONMapping] = {}
for path in sorted(root.rglob("*.json")):
record = load_publish_record(path)
document_id = require_non_empty_str(
record.get("document_id"), f"{path}.document_id"
)
records[document_id] = record
return records
def build_acl_expression(
visibility: str, policies: dict[str, dict[str, object]]
) -> str:
"""Resolve the ACL expression for a visibility."""
policy = policies.get(visibility)
if policy is None:
raise KnowledgeContractError(f"未注册 visibility ACL policy: {visibility}")
acl_template = policy.get("acl_expression_template")
return require_non_empty_str(acl_template, f"acl policy for {visibility}")
def build_event(
*,
action: str,
artifact_id: str,
document_id: str,
source_repo: str,
status: str,
reason: str,
target_ids: list[str],
) -> JSONMapping:
"""Build one append-only event record."""
compact_time = utc_now().replace(":", "").replace("-", "")
return {
"event_id": f"knowledge-{action}-{document_id}-{compact_time}",
"timestamp": utc_now(),
"artifact_id": artifact_id,
"document_id": document_id,
"source_repo": source_repo,
"action": action,
"status": status,
"reason": reason,
"target_ids": target_ids,
}
def artifact_document_name(document_id: str) -> str:
"""Return the stable remote document name used in Dify."""
return document_id
def artifact_snapshot_root(
deploy_root: Path,
environment: str,
source_repo: str,
artifact_id: str,
) -> Path:
"""Return the snapshot root for one artifact."""
return (
knowledge_paths(deploy_root, environment).artifacts_root
/ repo_slug(source_repo)
/ artifact_id
)
def record_path_for_document(
deploy_root: Path,
environment: str,
source_repo: str,
document_id: str,
) -> Path:
"""Return the publish-record path for a document."""
return (
knowledge_paths(deploy_root, environment).publish_records_root
/ repo_slug(source_repo)
/ f"{document_id}.json"
)
def event_path_for_record(
deploy_root: Path,
environment: str,
source_repo: str,
event_id: str,
) -> Path:
"""Return the audit event path for one immutable knowledge event."""
return (
knowledge_paths(deploy_root, environment).events_root
/ repo_slug(source_repo)
/ f"{event_id}.json"
)
def cast_mapping_list(value: object, field_path: str) -> list[JSONMapping]:
"""Cast a list payload into a list of mappings."""
items = require_list(value, field_path)
mappings: list[JSONMapping] = []
for index, item in enumerate(items):
mappings.append(require_mapping(item, f"{field_path}[{index}]"))
return mappings
def optional_mapping(value: object, field_path: str) -> JSONMapping | None:
"""Return an optional mapping."""
if value is None:
return None
return require_mapping(value, field_path)
def cast_str(value: object) -> str:
"""Cast a value known to be a string."""
return cast(str, value)