models
This commit is contained in:
@@ -1,18 +1,23 @@
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from pathlib import PurePosixPath
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
from typing import Literal
|
||||
|
||||
import yaml
|
||||
from pydantic import Field
|
||||
from pydantic import field_validator
|
||||
from pydantic import model_validator
|
||||
|
||||
from .common import SEMVER_RE
|
||||
from .common import SKILL_ID_RE
|
||||
from .common import StrictFrozenModel
|
||||
from .common import frozen_mapping
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from personal_mcp.registry.ingest.prompt import PromptFilesBundle
|
||||
|
||||
type PromptArgumentType = Literal[
|
||||
"string",
|
||||
"number",
|
||||
@@ -101,6 +106,18 @@ class PromptFrontmatter(StrictFrozenModel):
|
||||
raise ValueError("name must not contain reserved words anthropic or claude")
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def from_raw_yaml(cls, raw: str | None) -> "PromptFrontmatter":
|
||||
if raw is None:
|
||||
raise ValueError("missing YAML frontmatter")
|
||||
try:
|
||||
data = yaml.safe_load(raw)
|
||||
except yaml.YAMLError as e:
|
||||
raise ValueError(f"invalid YAML in frontmatter: {e}") from e
|
||||
if not isinstance(data, dict):
|
||||
raise TypeError("frontmatter must parse to an object")
|
||||
return cls.model_validate(data)
|
||||
|
||||
|
||||
class StoredPrompt(StrictFrozenModel):
|
||||
"""Normalized prompt document content with path and frontmatter for storage in the registry."""
|
||||
@@ -108,15 +125,43 @@ class StoredPrompt(StrictFrozenModel):
|
||||
prompt_id: str
|
||||
relpath: PurePosixPath
|
||||
content: str
|
||||
frontmatter: PromptFrontmatter | None = None
|
||||
frontmatter: PromptFrontmatter
|
||||
|
||||
@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)
|
||||
def parse_frontmatter_yaml(cls, value: PromptFrontmatter | str | None) -> PromptFrontmatter:
|
||||
if isinstance(value, PromptFrontmatter):
|
||||
return value
|
||||
return PromptFrontmatter.from_raw_yaml(value)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_contract(self) -> "StoredPrompt":
|
||||
parts = self.relpath.parts
|
||||
if len(parts) < 3 or parts[0] != "prompts":
|
||||
raise ValueError("prompt relpath must be under prompts/<slug>/")
|
||||
|
||||
prompt_dir_name = parts[1]
|
||||
if self.frontmatter.name != prompt_dir_name:
|
||||
raise ValueError("frontmatter name must exactly match prompt directory name")
|
||||
if self.frontmatter.x_personal_mcp.id != self.frontmatter.name:
|
||||
raise ValueError("x-personal-mcp.id must exactly match name")
|
||||
|
||||
expected_capability = f"resource://prompts/{self.frontmatter.name}/document"
|
||||
if expected_capability not in self.frontmatter.x_personal_mcp.capabilities:
|
||||
raise ValueError(f"capabilities must include {expected_capability}")
|
||||
|
||||
if self.prompt_id != self.frontmatter.x_personal_mcp.id:
|
||||
raise ValueError("prompt_id must exactly match x-personal-mcp.id")
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def from_bundle(cls, bundle: "PromptFilesBundle") -> "StoredPrompt":
|
||||
frontmatter = PromptFrontmatter.from_raw_yaml(bundle.prompt.frontmatter)
|
||||
return cls.model_validate(
|
||||
{
|
||||
"prompt_id": frontmatter.x_personal_mcp.id,
|
||||
"relpath": bundle.prompt.relpath,
|
||||
"content": bundle.prompt.content,
|
||||
"frontmatter": frontmatter,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -51,6 +51,18 @@ class SkillSummaryRecord(StrictFrozenModel):
|
||||
document_uri: str
|
||||
version: str
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: SkillRecord) -> "SkillSummaryRecord":
|
||||
return cls(
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
class PromptRecord(StrictFrozenModel):
|
||||
"""Registry record containing a fully resolved prompt document."""
|
||||
@@ -83,6 +95,103 @@ class PromptSummaryRecord(StrictFrozenModel):
|
||||
document_uri: str
|
||||
version: str
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: PromptRecord) -> "PromptSummaryRecord":
|
||||
return cls(
|
||||
prompt_id=record.prompt_id,
|
||||
name=record.name,
|
||||
description=record.description,
|
||||
tags=record.tags,
|
||||
capabilities=record.capabilities,
|
||||
document_uri=record.document_uri,
|
||||
version=record.version,
|
||||
)
|
||||
|
||||
|
||||
class SkillPatternPayload(StrictFrozenModel):
|
||||
"""Catalog payload model for skill pattern search results."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
version: str
|
||||
description: str
|
||||
tags: list[str]
|
||||
depends_on: list[str]
|
||||
capabilities: list[str]
|
||||
resources: list[str]
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: SkillRecord) -> "SkillPatternPayload":
|
||||
return cls(
|
||||
id=record.skill_id,
|
||||
name=record.name,
|
||||
version=record.version,
|
||||
description=record.description,
|
||||
tags=list(record.tags),
|
||||
depends_on=list(record.depends_on),
|
||||
capabilities=list(record.capabilities),
|
||||
resources=list(record.capabilities),
|
||||
)
|
||||
|
||||
|
||||
class SkillSummaryPayload(StrictFrozenModel):
|
||||
"""Catalog payload model for skill index summaries."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
tags: list[str]
|
||||
capabilities: list[str]
|
||||
version: str
|
||||
document_uri: str
|
||||
detail_uri: str
|
||||
resources: dict[str, str | list[str]]
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: SkillRecord) -> "SkillSummaryPayload":
|
||||
return cls(
|
||||
id=record.skill_id,
|
||||
name=record.name,
|
||||
description=record.description,
|
||||
tags=list(record.tags),
|
||||
capabilities=list(record.capabilities),
|
||||
version=record.version,
|
||||
document_uri=record.document_uri,
|
||||
detail_uri=f"resource://catalog/skills/{record.skill_id}",
|
||||
resources={
|
||||
"document": record.document_uri,
|
||||
"references": [
|
||||
f"resource://skills/{record.skill_id}/references/{ref_id}" for ref_id in sorted(record.references)
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class PromptSummaryPayload(StrictFrozenModel):
|
||||
"""Catalog payload model for prompt index summaries."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
tags: list[str]
|
||||
capabilities: list[str]
|
||||
version: str
|
||||
document_uri: str
|
||||
detail_uri: str
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: PromptRecord) -> "PromptSummaryPayload":
|
||||
return cls(
|
||||
id=record.prompt_id,
|
||||
name=record.name,
|
||||
description=record.description,
|
||||
tags=list(record.tags),
|
||||
capabilities=list(record.capabilities),
|
||||
version=record.version,
|
||||
document_uri=record.document_uri,
|
||||
detail_uri=f"resource://catalog/prompts/{record.prompt_id}",
|
||||
)
|
||||
|
||||
|
||||
class DocsRegistry(StrictFrozenModel):
|
||||
"""In-memory index of loaded skills, prompts, and docs content."""
|
||||
|
||||
@@ -4,6 +4,7 @@ from pathlib import PurePosixPath
|
||||
import yaml
|
||||
from pydantic import Field
|
||||
from pydantic import field_validator
|
||||
from pydantic import model_validator
|
||||
|
||||
from .common import SEMVER_RE
|
||||
from .common import SKILL_ID_RE
|
||||
@@ -107,6 +108,18 @@ class SkillFrontmatter(StrictFrozenModel):
|
||||
raise ValueError("name must not contain reserved words anthropic or claude")
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def from_raw_yaml(cls, raw: str | None) -> "SkillFrontmatter":
|
||||
if raw is None:
|
||||
raise ValueError("missing YAML frontmatter")
|
||||
try:
|
||||
data = yaml.safe_load(raw)
|
||||
except yaml.YAMLError as e:
|
||||
raise ValueError(f"invalid YAML in frontmatter: {e}") from e
|
||||
if not isinstance(data, dict):
|
||||
raise TypeError("frontmatter must parse to an object")
|
||||
return cls.model_validate(data)
|
||||
|
||||
|
||||
class StoredSkillReference(StrictFrozenModel):
|
||||
"""Structured representation of a skill reference markdown document."""
|
||||
@@ -124,15 +137,40 @@ class StoredSkill(StrictFrozenModel):
|
||||
relpath: PurePosixPath
|
||||
content: str
|
||||
frontmatter: SkillFrontmatter
|
||||
references: dict[str, StoredSkillReference] = Field(default_factory=dict)
|
||||
references: Mapping[str, StoredSkillReference] = Field(default_factory=frozen_mapping)
|
||||
|
||||
@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)
|
||||
def parse_frontmatter_yaml(cls, value: SkillFrontmatter | str | None) -> SkillFrontmatter:
|
||||
if isinstance(value, SkillFrontmatter):
|
||||
return value
|
||||
return SkillFrontmatter.from_raw_yaml(value)
|
||||
|
||||
@field_validator("references", mode="before")
|
||||
@classmethod
|
||||
def freeze_references(cls, value: Mapping[str, StoredSkillReference] | None) -> Mapping[str, StoredSkillReference]:
|
||||
return frozen_mapping(value)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_contract(self) -> "StoredSkill":
|
||||
parts = self.relpath.parts
|
||||
if len(parts) < 3 or parts[0] != "skills":
|
||||
raise ValueError("skill relpath must be under skills/<slug>/")
|
||||
|
||||
skill_dir_name = parts[1]
|
||||
if self.frontmatter.name != skill_dir_name:
|
||||
raise ValueError("frontmatter name must exactly match skill directory name")
|
||||
if self.frontmatter.x_personal_mcp.id != self.frontmatter.name:
|
||||
raise ValueError("x-personal-mcp.id must exactly match name")
|
||||
|
||||
expected_capability = f"resource://skills/{self.frontmatter.name}/document"
|
||||
if expected_capability not in self.frontmatter.x_personal_mcp.capabilities:
|
||||
raise ValueError(f"capabilities must include {expected_capability}")
|
||||
|
||||
if self.skill_id != self.frontmatter.x_personal_mcp.id:
|
||||
raise ValueError("skill_id must exactly match x-personal-mcp.id")
|
||||
|
||||
for ref_id, ref in self.references.items():
|
||||
if ref.ref_id != ref_id:
|
||||
raise ValueError(f"reference key must match ref_id: {ref_id}")
|
||||
return self
|
||||
|
||||
Reference in New Issue
Block a user