generated from coulomb/repo-seed
Curate package scaffold + flavor-agnostic SolutionPattern artifact with separate per-flavor rendering hints (OQ4): Resolution/Scope/Provenance sub-records, stable source-key id, semver bump helper, deterministic round-trip serialization. 7 new tests; suite 47/47 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
156 lines
5.9 KiB
Python
156 lines
5.9 KiB
Python
"""Solution Pattern schema (PRD §6.3 FR-U2; design OQ4) — T01.
|
|
|
|
A **Solution Pattern** is the curated, reviewed artifact a candidate pattern is
|
|
promoted into: a named, versioned record pairing a problem (or success) with one
|
|
or more recommended resolutions, written **flavor-agnostically**. Everything a
|
|
distributor needs to render a native artifact lives in a *separate*
|
|
``rendering_hints`` sub-structure, keyed by flavor — so the core stays neutral
|
|
(FR-A1/FR-A2) while Phase 3 distributors still get enough to render well (OQ4).
|
|
|
|
The artifact is the durable unit of the Pattern Catalog (T02): files originate,
|
|
the State Hub indexes (ADR-001). Serialization is deterministic (sorted keys) so
|
|
catalog files diff cleanly and re-saving an unchanged pattern is a no-op.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from dataclasses import asdict, dataclass, field, fields
|
|
from typing import Any, Optional
|
|
|
|
from ..core.schema import FLAVORS
|
|
|
|
SCHEMA_VERSION = 1
|
|
|
|
# Lifecycle of a catalogued pattern.
|
|
# provisional — promoted but below the distribution evidence bar (OQ5)
|
|
# approved — meets the bar; distribution-eligible (Phase 3)
|
|
# rejected — reviewed and declined; remembered so it is not re-surfaced
|
|
# superseded — replaced by a newer version of the same pattern id
|
|
STATUSES = ("provisional", "approved", "rejected", "superseded")
|
|
|
|
POLARITIES = ("problem", "success")
|
|
|
|
|
|
@dataclass
|
|
class Resolution:
|
|
"""One recommended resolution for the pattern's problem (FR-U2)."""
|
|
|
|
summary: str
|
|
detail: str = ""
|
|
steps: list[str] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class Scope:
|
|
"""Where the pattern applies (FR-X2 input). Empty list == unrestricted."""
|
|
|
|
repos: list[str] = field(default_factory=list)
|
|
domains: list[str] = field(default_factory=list)
|
|
flavors: list[str] = field(default_factory=list)
|
|
|
|
def __post_init__(self) -> None:
|
|
bad = [f for f in self.flavors if f not in FLAVORS]
|
|
if bad:
|
|
raise ValueError(f"unknown flavor(s) in scope {bad!r}; expected {FLAVORS}")
|
|
|
|
|
|
@dataclass
|
|
class Provenance:
|
|
"""Trace back to the detect candidate this pattern was promoted from."""
|
|
|
|
source_key: str # the detect Pattern.key — stable cluster identity
|
|
evidence: dict[str, Any] = field(default_factory=dict) # snapshot of the candidate
|
|
detected_at: Optional[str] = None
|
|
promoted_at: Optional[str] = None
|
|
|
|
|
|
@dataclass
|
|
class SolutionPattern:
|
|
"""A curated, versioned solution pattern (PRD §5 / §6.3)."""
|
|
|
|
id: str # stable, derived from provenance.source_key
|
|
name: str
|
|
version: str # semantic, e.g. "1.0.0"
|
|
polarity: str # problem | success
|
|
problem: str # human-readable description of the recurring situation
|
|
resolutions: list[Resolution] = field(default_factory=list)
|
|
scope: Scope = field(default_factory=Scope)
|
|
provenance: Provenance = field(default_factory=lambda: Provenance(source_key=""))
|
|
# per-flavor rendering hints, kept OUT of the agnostic core (OQ4):
|
|
# {"claude": {...}, "codex": {...}, "grok": {...}}
|
|
rendering_hints: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
status: str = "provisional"
|
|
distribution_ready: bool = False
|
|
created_at: Optional[str] = None
|
|
updated_at: Optional[str] = None
|
|
schema_version: int = SCHEMA_VERSION
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.polarity not in POLARITIES:
|
|
raise ValueError(f"unknown polarity {self.polarity!r}; expected {POLARITIES}")
|
|
if self.status not in STATUSES:
|
|
raise ValueError(f"unknown status {self.status!r}; expected {STATUSES}")
|
|
bad = [f for f in self.rendering_hints if f not in FLAVORS]
|
|
if bad:
|
|
raise ValueError(f"unknown flavor(s) in rendering_hints {bad!r}; expected {FLAVORS}")
|
|
|
|
# --- identity / versioning helpers -------------------------------------
|
|
|
|
@staticmethod
|
|
def make_id(source_key: str) -> str:
|
|
"""Stable catalog id from a detect candidate key (``polarity:type:locus``).
|
|
|
|
Identity is the source key, so re-promoting the same candidate maps to the
|
|
same pattern (dedup in T02), independent of wording or version.
|
|
"""
|
|
slug = re.sub(r"[^a-z0-9_]+", "-", source_key.lower()).strip("-")
|
|
return f"sp-{slug}"
|
|
|
|
@staticmethod
|
|
def bump_version(version: str, level: str = "patch") -> str:
|
|
"""Increment a ``major.minor.patch`` version string."""
|
|
parts = (version.split(".") + ["0", "0", "0"])[:3]
|
|
major, minor, patch = (int(p) for p in parts)
|
|
if level == "major":
|
|
major, minor, patch = major + 1, 0, 0
|
|
elif level == "minor":
|
|
minor, patch = minor + 1, 0
|
|
else:
|
|
patch += 1
|
|
return f"{major}.{minor}.{patch}"
|
|
|
|
# --- serialization ------------------------------------------------------
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
def to_json(self) -> str:
|
|
return json.dumps(self.to_dict(), sort_keys=True, indent=2)
|
|
|
|
@classmethod
|
|
def from_dict(cls, d: dict[str, Any]) -> "SolutionPattern":
|
|
d = dict(d)
|
|
resolutions = [Resolution(**{k: v for k, v in r.items() if k in _RESOLUTION_FIELDS})
|
|
for r in d.pop("resolutions", [])]
|
|
scope = d.pop("scope", None)
|
|
prov = d.pop("provenance", None)
|
|
obj = cls(**{k: v for k, v in d.items() if k in _PATTERN_FIELDS})
|
|
obj.resolutions = resolutions
|
|
if scope is not None:
|
|
obj.scope = Scope(**{k: v for k, v in scope.items() if k in _SCOPE_FIELDS})
|
|
if prov is not None:
|
|
obj.provenance = Provenance(**{k: v for k, v in prov.items() if k in _PROV_FIELDS})
|
|
return obj
|
|
|
|
@classmethod
|
|
def from_json(cls, s: str) -> "SolutionPattern":
|
|
return cls.from_dict(json.loads(s))
|
|
|
|
|
|
_PATTERN_FIELDS = {f.name for f in fields(SolutionPattern)}
|
|
_RESOLUTION_FIELDS = {f.name for f in fields(Resolution)}
|
|
_SCOPE_FIELDS = {f.name for f in fields(Scope)}
|
|
_PROV_FIELDS = {f.name for f in fields(Provenance)}
|