better data models

This commit is contained in:
John Lancaster
2026-06-21 16:53:43 -05:00
parent 34923b51d7
commit 37fa9b6c6f
8 changed files with 261 additions and 525 deletions
+15
View File
@@ -0,0 +1,15 @@
from .models.registry import DocsRegistry
from .models.registry import PromptRecord
from .models.registry import PromptSummaryRecord
from .models.registry import ReferenceRecord
from .models.registry import SkillRecord
from .models.registry import SkillSummaryRecord
__all__ = [
"DocsRegistry",
"PromptRecord",
"PromptSummaryRecord",
"ReferenceRecord",
"SkillRecord",
"SkillSummaryRecord",
]
-22
View File
@@ -1,22 +0,0 @@
from dataclasses import dataclass
@dataclass(frozen=True)
class RegistryIssue:
code: str
message: str
skill_id: str | None
path: str
hint: str
class DocsRegistryValidationError(Exception):
def __init__(self, errors: list[RegistryIssue]) -> None:
self.errors = errors
summary = "\n".join(
[
(f"{issue.code}: {issue.message} (skill={issue.skill_id or 'unknown'}, path={issue.path})")
for issue in errors
]
)
super().__init__(summary)
+181 -400
View File
@@ -1,448 +1,229 @@
from importlib.resources import files from __future__ import annotations
import importlib
from collections import defaultdict
from pathlib import Path
from pathlib import PurePosixPath from pathlib import PurePosixPath
from typing import Any
from pydantic import ValidationError import yaml
from ..skills.document_loader import _discover_top_level_references from personal_mcp.registry.ingest.document import MarkdownDocument
from ..skills.document_loader import _parse_frontmatter from personal_mcp.registry.ingest.prompt import PromptFilesBundle
from ..skills.document_loader import _validate_prompt_frontmatter from personal_mcp.registry.ingest.skill import SkillFilesBundle
from ..skills.document_loader import _validate_skill_frontmatter from personal_mcp.registry.models.common import ReferenceEntry
from ..skills.document_loader import _walk_markdown from personal_mcp.registry.models.registry import DocsRegistry
from .contracts import DocsRegistry from personal_mcp.registry.models.registry import PromptRecord
from .contracts import PromptRecord from personal_mcp.registry.models.registry import PromptSummaryRecord
from .contracts import PromptSummaryRecord from personal_mcp.registry.models.registry import ReferenceRecord
from .contracts import ReferenceRecord from personal_mcp.registry.models.registry import SkillRecord
from .contracts import SkillRecord from personal_mcp.registry.models.registry import SkillSummaryRecord
from .contracts import SkillSummaryRecord from personal_mcp.skills.document_loader import _normalize_docs_path
from .issues import DocsRegistryValidationError from personal_mcp.skills.document_loader import _reference_id_from_filename
from .issues import RegistryIssue from personal_mcp.skills.document_loader import _title_from_reference_filename
from personal_mcp.skills.document_loader import _validate_prompt_frontmatter
from personal_mcp.skills.document_loader import _validate_skill_frontmatter
def _ensure_no_cycles( def _parse_frontmatter(raw_frontmatter: str | None, *, path: PurePosixPath) -> dict[str, Any]:
skills_by_id: dict[str, SkillRecord], if raw_frontmatter is None:
) -> list[tuple[str, str]]: raise ValueError(f"missing YAML frontmatter: {path.as_posix()}")
state: dict[str, int] = {} parsed = yaml.safe_load(raw_frontmatter)
stack: list[str] = [] if not isinstance(parsed, dict):
cycles: set[tuple[str, str]] = set() raise TypeError(f"frontmatter must parse to an object: {path.as_posix()}")
return parsed
def visit(skill_id: str) -> None:
state[skill_id] = 1
stack.append(skill_id)
for dependency in skills_by_id[skill_id].depends_on: def _discover_reference_entries(bundle: SkillFilesBundle) -> dict[str, ReferenceEntry]:
if dependency not in skills_by_id: discovered: dict[str, ReferenceEntry] = {}
for reference_doc in bundle.references:
ref_id = _reference_id_from_filename(reference_doc.relpath.name)
if ref_id is None:
continue continue
dep_state = state.get(dependency, 0) discovered[ref_id] = ReferenceEntry(
if dep_state == 0: path=PurePosixPath("references").joinpath(reference_doc.relpath.name).as_posix(),
visit(dependency) title=_title_from_reference_filename(reference_doc.relpath.name),
continue
if dep_state == 1:
cycle_start = stack[stack.index(dependency)]
cycle_path = stack[stack.index(dependency) :] + [dependency]
cycles.add((cycle_start, " -> ".join(cycle_path)))
stack.pop()
state[skill_id] = 2
for skill_id in sorted(skills_by_id):
if state.get(skill_id, 0) == 0:
visit(skill_id)
return sorted(cycles)
def _load_skills(
*,
docs_markdown_by_path: dict[str, str],
skills_root,
) -> tuple[dict[str, SkillRecord], list[SkillSummaryRecord], list[RegistryIssue]]:
skills_by_id: dict[str, SkillRecord] = {}
summaries: list[SkillSummaryRecord] = []
issues: list[RegistryIssue] = []
for skill_dir in sorted(skills_root.iterdir(), key=lambda item: item.name):
if not skill_dir.is_dir():
continue
skill_dir_name = skill_dir.name
skill_rel_root = PurePosixPath("skills").joinpath(skill_dir_name)
skill_doc_relpath = skill_rel_root.joinpath("SKILL.md").as_posix()
skill_doc_file = skill_dir.joinpath("SKILL.md")
if not skill_doc_file.is_file():
issues.append(
RegistryIssue(
code="missing_skill_document",
message="missing required SKILL.md",
skill_id=skill_dir_name,
path=skill_doc_relpath,
hint="add docs/skills/<skill-id>/SKILL.md",
) )
) return discovered
continue
skill_markdown = skill_doc_file.read_text(encoding="utf-8")
try:
raw_frontmatter, _ = _parse_frontmatter(
skill_markdown,
path=skill_doc_relpath,
)
frontmatter = _validate_skill_frontmatter(
raw_frontmatter,
skill_dir_name=skill_dir_name,
)
except (ValueError, ValidationError) as exc:
issues.append(
RegistryIssue(
code="invalid_frontmatter",
message=str(exc),
skill_id=skill_dir_name,
path=skill_doc_relpath,
hint="fix SKILL.md YAML frontmatter to match the contract",
)
)
continue
effective_reference_entries = _discover_top_level_references(skill_dir=skill_dir) def _build_skill_record(
effective_reference_entries.update(frontmatter.x_personal_mcp.references) *, bundle: SkillFilesBundle, docs_by_relpath: dict[PurePosixPath, MarkdownDocument]
) -> SkillRecord:
frontmatter_raw = _parse_frontmatter(bundle.skill.frontmatter, path=bundle.skill.relpath)
frontmatter = _validate_skill_frontmatter(frontmatter_raw, skill_dir_name=bundle.slug)
metadata = frontmatter.x_personal_mcp
merged_entries = _discover_reference_entries(bundle)
merged_entries.update(dict(metadata.references))
references: dict[str, ReferenceRecord] = {} references: dict[str, ReferenceRecord] = {}
for ref_id, ref_entry in effective_reference_entries.items(): for ref_id, entry in sorted(merged_entries.items()):
ref_relpath = skill_rel_root.joinpath(ref_entry.path).as_posix() ref_relpath = PurePosixPath("skills").joinpath(bundle.slug).joinpath(entry.path)
if ref_relpath not in docs_markdown_by_path: if ref_relpath not in docs_by_relpath:
issues.append( raise KeyError(f"reference document not found for '{metadata.id}:{ref_id}' at {ref_relpath.as_posix()}")
RegistryIssue( ref_doc = docs_by_relpath[ref_relpath]
code="missing_reference",
message=f"reference target is missing for ref_id '{ref_id}'",
skill_id=frontmatter.name,
path=ref_relpath,
hint="fix x-personal-mcp.references path or add the referenced markdown file",
)
)
continue
references[ref_id] = ReferenceRecord( references[ref_id] = ReferenceRecord(
ref_id=ref_id, ref_id=ref_id,
uri=f"resource://skills/{frontmatter.name}/references/{ref_id}", uri=f"resource://skills/{metadata.id}/references/{ref_id}",
relpath=ref_relpath, relpath=ref_relpath.as_posix(),
mime_type=ref_entry.mime_type, mime_type=entry.mime_type,
title=ref_entry.title, title=entry.title,
content=docs_markdown_by_path[ref_relpath], content=ref_doc.content,
) )
skill_id = frontmatter.name return SkillRecord(
if skill_id in skills_by_id: skill_id=metadata.id,
issues.append(
RegistryIssue(
code="duplicate_skill_id",
message="duplicate skill id discovered",
skill_id=skill_id,
path=skill_doc_relpath,
hint="ensure each skill directory has a unique id",
)
)
continue
record = SkillRecord(
skill_id=skill_id,
name=frontmatter.name, name=frontmatter.name,
description=frontmatter.description, description=frontmatter.description,
version=frontmatter.x_personal_mcp.version, version=metadata.version,
tags=tuple(frontmatter.x_personal_mcp.tags), tags=tuple(metadata.tags),
capabilities=tuple(frontmatter.x_personal_mcp.capabilities), capabilities=tuple(metadata.capabilities),
depends_on=tuple(frontmatter.x_personal_mcp.depends_on), depends_on=tuple(metadata.depends_on),
document_uri=f"resource://skills/{skill_id}/document", document_uri=f"resource://skills/{metadata.id}/document",
document_relpath=skill_doc_relpath, document_relpath=bundle.skill.relpath.as_posix(),
document_content=skill_markdown, document_content=bundle.skill.content,
references=references, references=references,
) )
skills_by_id[skill_id] = record
summaries.append(
SkillSummaryRecord(
skill_id=record.skill_id,
name=record.name,
description=record.description,
tags=record.tags,
capabilities=record.capabilities,
document_uri=record.document_uri,
version=record.version,
)
)
return skills_by_id, summaries, issues
def _load_prompts( def _build_prompt_record(*, bundle: PromptFilesBundle) -> PromptRecord:
*, frontmatter_raw = _parse_frontmatter(bundle.prompt.frontmatter, path=bundle.prompt.relpath)
prompts_root, frontmatter = _validate_prompt_frontmatter(frontmatter_raw, prompt_dir_name=bundle.slug)
skills_by_id: dict[str, SkillRecord], metadata = frontmatter.x_personal_mcp
) -> tuple[dict[str, PromptRecord], list[PromptSummaryRecord], list[RegistryIssue]]:
prompts_by_id: dict[str, PromptRecord] = {}
prompt_summaries: list[PromptSummaryRecord] = []
issues: list[RegistryIssue] = []
discovered_prompt_docs: set[str] = set()
for prompt_dir in sorted(prompts_root.iterdir(), key=lambda item: item.name): return PromptRecord(
if not prompt_dir.is_dir(): prompt_id=metadata.id,
continue
prompt_dir_name = prompt_dir.name
prompt_rel_root = PurePosixPath("prompts").joinpath(prompt_dir_name)
prompt_doc_relpath = prompt_rel_root.joinpath("PROMPT.md").as_posix()
prompt_doc_file = prompt_dir.joinpath("PROMPT.md")
if not prompt_doc_file.is_file():
continue
discovered_prompt_docs.add(prompt_doc_relpath)
prompt_markdown = prompt_doc_file.read_text(encoding="utf-8")
try:
raw_frontmatter, _ = _parse_frontmatter(
prompt_markdown,
path=prompt_doc_relpath,
)
frontmatter = _validate_prompt_frontmatter(
raw_frontmatter,
prompt_dir_name=prompt_dir_name,
)
except (ValueError, ValidationError) as exc:
issues.append(
RegistryIssue(
code="invalid_prompt_frontmatter",
message=str(exc),
skill_id=prompt_dir_name,
path=prompt_doc_relpath,
hint="fix PROMPT.md YAML frontmatter to match the contract",
)
)
continue
prompt_id = frontmatter.name
if prompt_id in prompts_by_id:
issues.append(
RegistryIssue(
code="duplicate_prompt_id",
message="duplicate prompt id discovered",
skill_id=prompt_id,
path=prompt_doc_relpath,
hint="ensure each prompt directory has a unique id",
)
)
continue
if prompt_id in skills_by_id:
issues.append(
RegistryIssue(
code="prompt_skill_id_collision",
message="prompt id collides with an existing skill id",
skill_id=prompt_id,
path=prompt_doc_relpath,
hint="use a unique prompt id that does not match a skill id",
)
)
continue
prompt_record = PromptRecord(
prompt_id=prompt_id,
name=frontmatter.name, name=frontmatter.name,
description=frontmatter.description, description=frontmatter.description,
version=frontmatter.x_personal_mcp.version, version=metadata.version,
tags=tuple(frontmatter.x_personal_mcp.tags), tags=tuple(metadata.tags),
capabilities=tuple(frontmatter.x_personal_mcp.capabilities), capabilities=tuple(metadata.capabilities),
arguments=frontmatter.x_personal_mcp.arguments, arguments=dict(metadata.arguments),
document_uri=f"resource://prompts/{prompt_id}/document", document_uri=f"resource://prompts/{metadata.id}/document",
document_relpath=prompt_doc_relpath, document_relpath=bundle.prompt.relpath.as_posix(),
document_content=prompt_markdown, document_content=bundle.prompt.content,
)
prompts_by_id[prompt_id] = prompt_record
prompt_summaries.append(
PromptSummaryRecord(
prompt_id=prompt_record.prompt_id,
name=prompt_record.name,
description=prompt_record.description,
tags=prompt_record.tags,
capabilities=prompt_record.capabilities,
document_uri=prompt_record.document_uri,
version=prompt_record.version,
)
) )
return prompts_by_id, prompt_summaries, issues
def _to_summary(skill: SkillRecord) -> SkillSummaryRecord:
return SkillSummaryRecord(
skill_id=skill.skill_id,
name=skill.name,
description=skill.description,
tags=skill.tags,
capabilities=skill.capabilities,
document_uri=skill.document_uri,
version=skill.version,
)
def _collect_relationship_and_uri_issues( def _to_prompt_summary(prompt: PromptRecord) -> PromptSummaryRecord:
*, return PromptSummaryRecord(
skills_by_id: dict[str, SkillRecord], prompt_id=prompt.prompt_id,
name=prompt.name,
description=prompt.description,
tags=prompt.tags,
capabilities=prompt.capabilities,
document_uri=prompt.document_uri,
version=prompt.version,
)
def _build_tag_index_skills(
skills_in_order: tuple[str, ...], skills_by_id: dict[str, SkillRecord]
) -> dict[str, tuple[str, ...]]:
tag_index: defaultdict[str, list[str]] = defaultdict(list)
for skill_id in skills_in_order:
for tag in skills_by_id[skill_id].tags:
tag_index[tag].append(skill_id)
return {tag: tuple(ids) for tag, ids in sorted(tag_index.items())}
def _build_capability_index(
skills_in_order: tuple[str, ...], skills_by_id: dict[str, SkillRecord]
) -> dict[str, tuple[str, ...]]:
capability_index: defaultdict[str, list[str]] = defaultdict(list)
for skill_id in skills_in_order:
for capability in skills_by_id[skill_id].capabilities:
capability_index[capability].append(skill_id)
return {capability: tuple(ids) for capability, ids in sorted(capability_index.items())}
def _build_tag_index_prompts(
prompts_in_order: tuple[str, ...],
prompts_by_id: dict[str, PromptRecord], prompts_by_id: dict[str, PromptRecord],
) -> list[RegistryIssue]: ) -> dict[str, tuple[str, ...]]:
issues: list[RegistryIssue] = [] tag_index: defaultdict[str, list[str]] = defaultdict(list)
for prompt_id in prompts_in_order:
for skill_id, record in sorted(skills_by_id.items()): for tag in prompts_by_id[prompt_id].tags:
for dependency in record.depends_on: tag_index[tag].append(prompt_id)
if dependency == skill_id: return {tag: tuple(ids) for tag, ids in sorted(tag_index.items())}
issues.append(
RegistryIssue(
code="self_dependency",
message="skill must not depend on itself",
skill_id=skill_id,
path=record.document_relpath,
hint="remove the skill id from depends_on",
)
)
elif dependency not in skills_by_id:
issues.append(
RegistryIssue(
code="missing_dependency",
message=f"depends_on target '{dependency}' does not exist",
skill_id=skill_id,
path=record.document_relpath,
hint="add the missing skill or remove it from depends_on",
)
)
for cycle_start, cycle in _ensure_no_cycles(skills_by_id):
issues.append(
RegistryIssue(
code="dependency_cycle",
message=f"depends_on cycle detected: {cycle}",
skill_id=cycle_start,
path=skills_by_id[cycle_start].document_relpath,
hint="remove at least one dependency edge in the cycle",
)
)
seen_uris: set[str] = set()
for skill_id, record in sorted(skills_by_id.items()):
uris = [record.document_uri] + [ref.uri for ref in record.references.values()]
for uri in uris:
if uri in seen_uris:
issues.append(
RegistryIssue(
code="duplicate_uri",
message=f"duplicate resource URI generated: {uri}",
skill_id=skill_id,
path=record.document_relpath,
hint="ensure unique skill ids and reference ids",
)
)
seen_uris.add(uri)
for prompt_id, record in sorted(prompts_by_id.items()):
uri = record.document_uri
if uri in seen_uris:
issues.append(
RegistryIssue(
code="duplicate_uri",
message=f"duplicate resource URI generated: {uri}",
skill_id=prompt_id,
path=record.document_relpath,
hint="ensure unique prompt ids",
)
)
seen_uris.add(uri)
return issues
def load_docs_registry( def _resolve_docs_root(*, package_anchor: str, docs_root: str) -> Path:
*, package = importlib.import_module(package_anchor)
package_anchor: str, package_file = getattr(package, "__file__", None)
docs_root: str = "docs", if package_file is None:
) -> DocsRegistry: raise ValueError(f"package anchor '{package_anchor}' has no file location")
docs_dir = files(package_anchor).joinpath(docs_root)
if not docs_dir.is_dir(): resolved = Path(package_file).resolve().parent.joinpath(docs_root).resolve()
raise DocsRegistryValidationError( if not resolved.exists() or not resolved.is_dir():
[ raise FileNotFoundError(f"docs root does not exist or is not a directory: {resolved}")
RegistryIssue( return resolved
code="missing_docs_root",
message="docs root directory does not exist",
skill_id=None,
path=docs_root,
hint="configure docs_root to a valid packaged docs path",
)
]
)
docs_markdown_by_path = {
relpath: doc_file.read_text(encoding="utf-8") for relpath, doc_file in _walk_markdown(docs_dir)
}
skills_root = docs_dir.joinpath("skills") def load_docs_registry(*, package_anchor: str, docs_root: str = "docs") -> DocsRegistry:
if not skills_root.is_dir(): docs_path = _resolve_docs_root(package_anchor=package_anchor, docs_root=docs_root)
raise DocsRegistryValidationError( docs = MarkdownDocument.from_root(docs_path)
[
RegistryIssue(
code="missing_skills_root",
message="skills directory does not exist under docs root",
skill_id=None,
path=f"{docs_root}/skills",
hint="ensure docs/skills is included in packaged docs",
)
]
)
skills_by_id, summaries, skill_issues = _load_skills( docs_markdown_by_path = {_normalize_docs_path(relpath.as_posix()): doc.content for relpath, doc in docs.items()}
docs_markdown_by_path=docs_markdown_by_path,
skills_root=skills_root, skill_bundles = SkillFilesBundle.from_docs(docs.values())
) prompt_bundles = PromptFilesBundle.from_docs(docs.values())
docs_by_relpath = {doc.relpath: doc for doc in docs.values()}
skills_by_id: dict[str, SkillRecord] = {}
skills_in_load_order: list[str] = []
for bundle in skill_bundles:
record = _build_skill_record(bundle=bundle, docs_by_relpath=docs_by_relpath)
if record.skill_id in skills_by_id:
raise ValueError(f"duplicate skill_id detected: {record.skill_id}")
skills_by_id[record.skill_id] = record
skills_in_load_order.append(record.skill_id)
prompts_by_id: dict[str, PromptRecord] = {} prompts_by_id: dict[str, PromptRecord] = {}
prompt_summaries: list[PromptSummaryRecord] = [] prompts_in_load_order: list[str] = []
prompt_issues: list[RegistryIssue] = [] for bundle in prompt_bundles:
record = _build_prompt_record(bundle=bundle)
if record.prompt_id in prompts_by_id:
raise ValueError(f"duplicate prompt_id detected: {record.prompt_id}")
if record.prompt_id in skills_by_id:
raise ValueError(f"prompt_id collides with existing skill_id: {record.prompt_id}")
prompts_by_id[record.prompt_id] = record
prompts_in_load_order.append(record.prompt_id)
prompts_root = docs_dir.joinpath("prompts") for skill_id, skill in skills_by_id.items():
if prompts_root.is_dir(): for dependency in skill.depends_on:
prompts_by_id, prompt_summaries, prompt_issues = _load_prompts( if dependency not in skills_by_id:
prompts_root=prompts_root, raise ValueError(f"skill '{skill_id}' depends_on unknown skill '{dependency}'")
skills_by_id=skills_by_id,
)
issues = [ skills_in_order_tuple = tuple(skills_in_load_order)
*skill_issues, prompts_in_order_tuple = tuple(prompts_in_load_order)
*prompt_issues,
*_collect_relationship_and_uri_issues(
skills_by_id=skills_by_id,
prompts_by_id=prompts_by_id,
),
]
if issues:
raise DocsRegistryValidationError(issues)
skill_ids = tuple(sorted(skills_by_id))
summary_by_id = {summary.skill_id: summary for summary in summaries}
ordered_summaries = tuple(summary_by_id[skill_id] for skill_id in skill_ids)
prompt_ids = tuple(sorted(prompts_by_id))
prompt_summary_by_id = {summary.prompt_id: summary for summary in prompt_summaries}
ordered_prompt_summaries = tuple(prompt_summary_by_id[prompt_id] for prompt_id in prompt_ids)
tag_index: dict[str, list[str]] = {}
capability_index: dict[str, list[str]] = {}
prompt_tag_index: dict[str, list[str]] = {}
for skill_id in skill_ids:
record = skills_by_id[skill_id]
for tag in record.tags:
tag_index.setdefault(tag, []).append(skill_id)
for capability in record.capabilities:
capability_index.setdefault(capability, []).append(skill_id)
for prompt_id in prompt_ids:
record = prompts_by_id[prompt_id]
for tag in record.tags:
prompt_tag_index.setdefault(tag, []).append(prompt_id)
return DocsRegistry( return DocsRegistry(
skills_by_id=skills_by_id, skills_by_id=skills_by_id,
skills_in_load_order=skill_ids, skills_in_load_order=skills_in_order_tuple,
skills_summary_in_load_order=ordered_summaries, skills_summary_in_load_order=tuple(_to_summary(skills_by_id[skill_id]) for skill_id in skills_in_order_tuple),
docs_markdown_by_path=docs_markdown_by_path, docs_markdown_by_path=docs_markdown_by_path,
docs_markdown_path_index=tuple(sorted(docs_markdown_by_path)), docs_markdown_path_index=tuple(sorted(docs_markdown_by_path)),
tag_to_skill_ids={key: tuple(sorted(values)) for key, values in sorted(tag_index.items())}, tag_to_skill_ids=_build_tag_index_skills(skills_in_order_tuple, skills_by_id),
capability_to_skill_ids={key: tuple(sorted(values)) for key, values in sorted(capability_index.items())}, capability_to_skill_ids=_build_capability_index(skills_in_order_tuple, skills_by_id),
prompts_by_id=prompts_by_id, prompts_by_id=prompts_by_id,
prompts_in_load_order=prompt_ids, prompts_in_load_order=prompts_in_order_tuple,
prompts_summary_in_load_order=ordered_prompt_summaries, prompts_summary_in_load_order=tuple(
tag_to_prompt_ids={key: tuple(sorted(values)) for key, values in sorted(prompt_tag_index.items())}, _to_prompt_summary(prompts_by_id[prompt_id]) for prompt_id in prompts_in_order_tuple
),
tag_to_prompt_ids=_build_tag_index_prompts(prompts_in_order_tuple, prompts_by_id),
) )
@@ -1,56 +0,0 @@
from pathlib import PurePosixPath
from typing import Self
from .common import StrictFrozenModel
class MarkdownDocumentModel(StrictFrozenModel):
"""Normalized markdown document content with path and frontmatter."""
relpath: str
content: str
frontmatter: str | None = None
skill_slug: str | None = None
class SkillFilesBundleModel(StrictFrozenModel):
"""Grouped skill documents partitioned into skill, references, and other files."""
slug: str
skill: MarkdownDocumentModel
references: tuple[MarkdownDocumentModel, ...] = ()
other: tuple[MarkdownDocumentModel, ...] = ()
@classmethod
def from_parts(
cls,
*,
slug: str,
skill: MarkdownDocumentModel,
references: tuple[MarkdownDocumentModel, ...] = (),
other: tuple[MarkdownDocumentModel, ...] = (),
) -> Self:
# Ensure deterministic ordering even when the source collection is unordered.
references_sorted = tuple(sorted(references, key=lambda doc: doc.relpath))
other_sorted = tuple(sorted(other, key=lambda doc: doc.relpath))
return cls(
slug=slug,
skill=skill,
references=references_sorted,
other=other_sorted,
)
def to_markdown_document_model(
*,
relpath: PurePosixPath,
content: str,
frontmatter: str | None,
skill_slug: str | None,
) -> MarkdownDocumentModel:
return MarkdownDocumentModel(
relpath=relpath.as_posix(),
content=content,
frontmatter=frontmatter,
skill_slug=skill_slug,
)
+17 -4
View File
@@ -1,8 +1,10 @@
import re import re
from collections.abc import Mapping from collections.abc import Mapping
from pathlib import PurePosixPath
from typing import Any from typing import Any
from typing import Literal from typing import Literal
import yaml
from pydantic import Field from pydantic import Field
from pydantic import field_validator from pydantic import field_validator
@@ -100,10 +102,21 @@ class PromptFrontmatter(StrictFrozenModel):
return value return value
class PromptDocumentModel(StrictFrozenModel): class StoredPrompt(StrictFrozenModel):
"""Structured representation of a prompt markdown document.""" """Normalized prompt document content with path and frontmatter for storage in the registry."""
prompt_id: str prompt_id: str
relpath: str relpath: PurePosixPath
content: str content: str
frontmatter: PromptFrontmatter frontmatter: PromptFrontmatter | None = None
@field_validator("frontmatter", mode="before")
@classmethod
def parse_frontmatter_yaml(cls, value: str | None):
if value is None:
return None
try:
data = yaml.safe_load(value)
except yaml.YAMLError as e:
raise ValueError(f"invalid YAML in frontmatter: {e}") from e
return PromptFrontmatter.model_validate(data)
+23 -10
View File
@@ -1,6 +1,7 @@
from collections.abc import Mapping from collections.abc import Mapping
from pathlib import PurePosixPath from pathlib import PurePosixPath
import yaml
from pydantic import Field from pydantic import Field
from pydantic import field_validator from pydantic import field_validator
@@ -107,19 +108,31 @@ class SkillFrontmatter(StrictFrozenModel):
return value return value
class SkillDocumentModel(StrictFrozenModel): class StoredSkillReference(StrictFrozenModel):
"""Structured representation of a skill markdown document."""
skill_id: str
relpath: PurePosixPath
content: str
frontmatter: SkillFrontmatter
class SkillReferenceDocumentModel(StrictFrozenModel):
"""Structured representation of a skill reference markdown document.""" """Structured representation of a skill reference markdown document."""
ref_id: str ref_id: str
relpath: PurePosixPath relpath: PurePosixPath
content: str content: str
entry: ReferenceEntry entry: ReferenceEntry
class StoredSkill(StrictFrozenModel):
"""Structured representation of a skill markdown document."""
skill_id: str
relpath: PurePosixPath
content: str
frontmatter: SkillFrontmatter
references: dict[str, StoredSkillReference] = Field(default_factory=dict)
@field_validator("frontmatter", mode="before")
@classmethod
def parse_frontmatter_yaml(cls, value: str | None):
if value is None:
return None
try:
data = yaml.safe_load(value)
except yaml.YAMLError as e:
raise ValueError(f"invalid YAML in frontmatter: {e}") from e
return SkillFrontmatter.model_validate(data)
@@ -7,7 +7,6 @@ import pytest
from personal_mcp.registry.ingest.document import MarkdownDocument from personal_mcp.registry.ingest.document import MarkdownDocument
from personal_mcp.registry.ingest.prompt import PromptFilesBundle from personal_mcp.registry.ingest.prompt import PromptFilesBundle
from personal_mcp.registry.ingest.skill import SkillFilesBundle from personal_mcp.registry.ingest.skill import SkillFilesBundle
from personal_mcp.registry.load import load_docs_registry
pytestmark = pytest.mark.unit pytestmark = pytest.mark.unit
@@ -41,10 +40,3 @@ class TestCurrentDocsIngestion:
bundles = PromptFilesBundle.from_docs(docs.values()) bundles = PromptFilesBundle.from_docs(docs.values())
assert {bundle.slug for bundle in bundles} == expected_slugs assert {bundle.slug for bundle in bundles} == expected_slugs
def test_loads_current_registry(self) -> None:
"""Ensures the current docs registry loads without validation errors."""
registry = load_docs_registry(package_anchor="personal_mcp", docs_root="../../docs")
assert set(registry.skills_by_id) == {path.parent.name for path in DOCS_ROOT.glob("skills/*/SKILL.md")}
assert set(registry.prompts_by_id) == {path.parent.name for path in DOCS_ROOT.glob("prompts/*/PROMPT.md")}