mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(content): structured content schema models + renderers
This commit is contained in:
@@ -0,0 +1,45 @@
|
|||||||
|
"""Structured agent-content schema — the RoboCo content standard.
|
||||||
|
|
||||||
|
Public surface: the content models, the ``CONTENT_MODELS`` registry, the
|
||||||
|
``validate_content`` entry point, and the ``ContentValidationError`` raised on
|
||||||
|
failure. See :mod:`.models` for the per-type schemas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .enums import Severity, Verdict
|
||||||
|
from .models import (
|
||||||
|
CONTENT_MODELS,
|
||||||
|
AcVerdict,
|
||||||
|
AuditorNote,
|
||||||
|
DeveloperNote,
|
||||||
|
DocNote,
|
||||||
|
Finding,
|
||||||
|
PrReviewContent,
|
||||||
|
QaNote,
|
||||||
|
ResumptionNote,
|
||||||
|
TaskDescription,
|
||||||
|
WorkUnit,
|
||||||
|
required_shape,
|
||||||
|
validate_content,
|
||||||
|
)
|
||||||
|
from .validators import ContentValidationError
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CONTENT_MODELS",
|
||||||
|
"AcVerdict",
|
||||||
|
"AuditorNote",
|
||||||
|
"ContentValidationError",
|
||||||
|
"DeveloperNote",
|
||||||
|
"DocNote",
|
||||||
|
"Finding",
|
||||||
|
"PrReviewContent",
|
||||||
|
"QaNote",
|
||||||
|
"ResumptionNote",
|
||||||
|
"Severity",
|
||||||
|
"TaskDescription",
|
||||||
|
"Verdict",
|
||||||
|
"WorkUnit",
|
||||||
|
"required_shape",
|
||||||
|
"validate_content",
|
||||||
|
]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Enumerations for structured agent content.
|
||||||
|
|
||||||
|
These are the controlled vocabularies the content schema models
|
||||||
|
(:mod:`roboco.foundation.policy.content.models`) validate against. The cell
|
||||||
|
``Team`` vocabulary is reused from :mod:`roboco.foundation.identity` rather
|
||||||
|
than redefined here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class Severity(StrEnum):
|
||||||
|
"""Severity ladder for a PR-review finding (worst → least)."""
|
||||||
|
|
||||||
|
BLOCKER = "blocker" # must fix before merge
|
||||||
|
MAJOR = "major" # significant defect
|
||||||
|
MINOR = "minor" # small defect, fix advised
|
||||||
|
NIT = "nit" # cosmetic / preference
|
||||||
|
|
||||||
|
|
||||||
|
class Verdict(StrEnum):
|
||||||
|
"""Outcome of a review.
|
||||||
|
|
||||||
|
``approved`` / ``changes_requested`` describe a PR review; ``passed`` /
|
||||||
|
``failed`` describe a QA or in-path gate review. All four are valid
|
||||||
|
``PrReviewContent`` verdicts; ``QaNote`` accepts only ``passed`` /
|
||||||
|
``failed``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
APPROVED = "approved"
|
||||||
|
CHANGES_REQUESTED = "changes_requested"
|
||||||
|
PASSED = "passed"
|
||||||
|
FAILED = "failed"
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
"""Structured content schema models.
|
||||||
|
|
||||||
|
Every human-facing artifact an agent produces — PR-review comments, task
|
||||||
|
notes, task descriptions, resumption context — is one of these typed models.
|
||||||
|
Each model:
|
||||||
|
|
||||||
|
- validates its named fields (non-trivial, required, controlled vocab);
|
||||||
|
- coerces a lone scalar where a list is declared (graceful, never a hard
|
||||||
|
reject of the well-intentioned single-item case);
|
||||||
|
- renders to canonical labeled markdown via ``render_markdown()``.
|
||||||
|
|
||||||
|
The structured payload is the source of truth; the rendered markdown is the
|
||||||
|
derived mirror written to the Task's TEXT note columns and to PR comment
|
||||||
|
bodies. ``CONTENT_MODELS`` maps the gateway's content-type key to its model;
|
||||||
|
``validate_content`` is the single entry point that turns a raw payload into a
|
||||||
|
validated model (or a ``ContentValidationError``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import (
|
||||||
|
BaseModel,
|
||||||
|
ConfigDict,
|
||||||
|
Field,
|
||||||
|
ValidationError,
|
||||||
|
ValidationInfo,
|
||||||
|
field_validator,
|
||||||
|
model_validator,
|
||||||
|
)
|
||||||
|
|
||||||
|
from roboco.foundation.identity import CELL_TEAMS, Team
|
||||||
|
|
||||||
|
from .enums import Severity, Verdict
|
||||||
|
from .validators import ContentValidationError, coerce_to_list, reject_trivial
|
||||||
|
|
||||||
|
_SUMMARY_MIN = 10
|
||||||
|
|
||||||
|
|
||||||
|
class _Base(BaseModel):
|
||||||
|
"""Shared config: drop unknown keys (graceful), validate assignment."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="ignore", validate_assignment=True)
|
||||||
|
|
||||||
|
|
||||||
|
class _Content(_Base):
|
||||||
|
"""A top-level content type that renders to canonical markdown."""
|
||||||
|
|
||||||
|
def render_markdown(self) -> str: # pragma: no cover - overridden
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Markdown helpers
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _bullets(items: list[str]) -> str:
|
||||||
|
return "\n".join(f"- {i.strip()}" for i in items if i and i.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _section(title: str, body: str) -> str:
|
||||||
|
body = body.strip()
|
||||||
|
return f"## {title}\n{body}" if body else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _join(parts: list[str]) -> str:
|
||||||
|
return "\n\n".join(p for p in parts if p).strip()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Sub-models
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
class Finding(_Base):
|
||||||
|
"""One PR-review finding — file + line + expected vs actual."""
|
||||||
|
|
||||||
|
file: str
|
||||||
|
line: int | None = None
|
||||||
|
severity: Severity
|
||||||
|
criterion: str | None = None
|
||||||
|
expected: str
|
||||||
|
actual: str
|
||||||
|
|
||||||
|
@field_validator("file", "expected", "actual")
|
||||||
|
@classmethod
|
||||||
|
def _nontrivial(cls, v: str, info: ValidationInfo) -> str:
|
||||||
|
return reject_trivial(v, field=info.field_name or "field")
|
||||||
|
|
||||||
|
|
||||||
|
class WorkUnit(_Base):
|
||||||
|
"""One cell's slice of a task description."""
|
||||||
|
|
||||||
|
team: Team
|
||||||
|
summary: str
|
||||||
|
items: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
@field_validator("items", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _coerce_items(cls, v: Any) -> Any:
|
||||||
|
return coerce_to_list(v)
|
||||||
|
|
||||||
|
@field_validator("team")
|
||||||
|
@classmethod
|
||||||
|
def _cell_team_only(cls, v: Team) -> Team:
|
||||||
|
if v not in CELL_TEAMS:
|
||||||
|
raise ValueError(
|
||||||
|
f"team must be a cell ({', '.join(t.value for t in CELL_TEAMS)})"
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("summary")
|
||||||
|
@classmethod
|
||||||
|
def _nontrivial_summary(cls, v: str) -> str:
|
||||||
|
return reject_trivial(v, field="summary")
|
||||||
|
|
||||||
|
@field_validator("items")
|
||||||
|
@classmethod
|
||||||
|
def _nonempty_items(cls, v: list[str]) -> list[str]:
|
||||||
|
cleaned = [i for i in v if i and i.strip()]
|
||||||
|
if not cleaned:
|
||||||
|
raise ValueError("items must list at least one work item")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
class AcVerdict(_Base):
|
||||||
|
"""One acceptance-criterion verdict from a QA review."""
|
||||||
|
|
||||||
|
criterion: str
|
||||||
|
status: Literal["verified", "failed", "na"]
|
||||||
|
how: str
|
||||||
|
|
||||||
|
@field_validator("criterion", "how")
|
||||||
|
@classmethod
|
||||||
|
def _nontrivial(cls, v: str, info: ValidationInfo) -> str:
|
||||||
|
return reject_trivial(v, field=info.field_name or "field")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Top-level content models
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
class PrReviewContent(_Content):
|
||||||
|
"""A PR-review comment / reviewer verdict."""
|
||||||
|
|
||||||
|
summary: str
|
||||||
|
findings: list[Finding] = Field(default_factory=list)
|
||||||
|
verdict: Verdict
|
||||||
|
|
||||||
|
@field_validator("findings", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _coerce_findings(cls, v: Any) -> Any:
|
||||||
|
return coerce_to_list(v)
|
||||||
|
|
||||||
|
@field_validator("summary")
|
||||||
|
@classmethod
|
||||||
|
def _nontrivial_summary(cls, v: str) -> str:
|
||||||
|
return reject_trivial(v, field="summary", min_chars=_SUMMARY_MIN)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _findings_required_for_negative(self) -> PrReviewContent:
|
||||||
|
if self.verdict in (Verdict.CHANGES_REQUESTED, Verdict.FAILED) and not (
|
||||||
|
self.findings
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"findings must be non-empty when verdict is changes_requested or failed"
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def render_markdown(self) -> str:
|
||||||
|
parts = [_section("Summary", self.summary)]
|
||||||
|
if self.findings:
|
||||||
|
rows = [
|
||||||
|
"| File | Line | Severity | Expected → Actual |",
|
||||||
|
"| --- | --- | --- | --- |",
|
||||||
|
]
|
||||||
|
for f in self.findings:
|
||||||
|
loc = str(f.line) if f.line is not None else "—"
|
||||||
|
crit = f" ({f.criterion})" if f.criterion else ""
|
||||||
|
rows.append(
|
||||||
|
f"| `{f.file}`{crit} | {loc} | {f.severity.value} "
|
||||||
|
f"| {f.expected} → {f.actual} |"
|
||||||
|
)
|
||||||
|
parts.append("## Findings\n" + "\n".join(rows))
|
||||||
|
parts.append(_section("Verdict", self.verdict.value.replace("_", " ")))
|
||||||
|
return _join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskDescription(_Content):
|
||||||
|
"""A well-formed task description (shared by PM delegate + Intake draft)."""
|
||||||
|
|
||||||
|
objective: str
|
||||||
|
what_this_builds: list[str] = Field(default_factory=list)
|
||||||
|
the_work: list[WorkUnit] = Field(default_factory=list)
|
||||||
|
notes: list[str] = Field(default_factory=list)
|
||||||
|
acceptance_criteria: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
@field_validator(
|
||||||
|
"what_this_builds",
|
||||||
|
"the_work",
|
||||||
|
"notes",
|
||||||
|
"acceptance_criteria",
|
||||||
|
mode="before",
|
||||||
|
)
|
||||||
|
@classmethod
|
||||||
|
def _coerce(cls, v: Any) -> Any:
|
||||||
|
return coerce_to_list(v)
|
||||||
|
|
||||||
|
@field_validator("objective")
|
||||||
|
@classmethod
|
||||||
|
def _nontrivial_objective(cls, v: str) -> str:
|
||||||
|
return reject_trivial(v, field="objective", min_chars=_SUMMARY_MIN)
|
||||||
|
|
||||||
|
@field_validator("the_work")
|
||||||
|
@classmethod
|
||||||
|
def _nonempty_work(cls, v: list[WorkUnit]) -> list[WorkUnit]:
|
||||||
|
if not v:
|
||||||
|
raise ValueError("the_work must contain at least one work unit")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("acceptance_criteria")
|
||||||
|
@classmethod
|
||||||
|
def _nonempty_ac(cls, v: list[str]) -> list[str]:
|
||||||
|
cleaned = [i for i in v if i and i.strip()]
|
||||||
|
if not cleaned:
|
||||||
|
raise ValueError("acceptance_criteria must list at least one criterion")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
def render_markdown(self) -> str:
|
||||||
|
parts = [_section("Objective", self.objective)]
|
||||||
|
if self.what_this_builds:
|
||||||
|
parts.append(_section("What This Builds", _bullets(self.what_this_builds)))
|
||||||
|
if self.the_work:
|
||||||
|
units = []
|
||||||
|
for u in self.the_work:
|
||||||
|
head = f"**{u.team.value.replace('_', ' ').title()}** — {u.summary}"
|
||||||
|
units.append(f"{head}\n{_bullets(u.items)}")
|
||||||
|
parts.append("## The Work\n" + "\n\n".join(units))
|
||||||
|
if self.notes:
|
||||||
|
parts.append(_section("Notes", _bullets(self.notes)))
|
||||||
|
parts.append(
|
||||||
|
_section("Acceptance Criteria", _bullets(self.acceptance_criteria))
|
||||||
|
)
|
||||||
|
return _join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
class ResumptionNote(_Content):
|
||||||
|
"""The human handoff that lives in ``quick_context`` (no machine markers)."""
|
||||||
|
|
||||||
|
done: str
|
||||||
|
next: str
|
||||||
|
where_to_look: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
@field_validator("where_to_look", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _coerce(cls, v: Any) -> Any:
|
||||||
|
return coerce_to_list(v)
|
||||||
|
|
||||||
|
@field_validator("done", "next")
|
||||||
|
@classmethod
|
||||||
|
def _nontrivial(cls, v: str, info: ValidationInfo) -> str:
|
||||||
|
return reject_trivial(v, field=info.field_name or "field")
|
||||||
|
|
||||||
|
def render_markdown(self) -> str:
|
||||||
|
parts = [_section("Done", self.done), _section("Next", self.next)]
|
||||||
|
if self.where_to_look:
|
||||||
|
parts.append(_section("Where to look", _bullets(self.where_to_look)))
|
||||||
|
return _join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
class DeveloperNote(_Content):
|
||||||
|
"""A developer's task note."""
|
||||||
|
|
||||||
|
summary: str
|
||||||
|
changes: list[str] = Field(default_factory=list)
|
||||||
|
risks: list[str] = Field(default_factory=list)
|
||||||
|
follow_ups: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
@field_validator("changes", "risks", "follow_ups", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _coerce(cls, v: Any) -> Any:
|
||||||
|
return coerce_to_list(v)
|
||||||
|
|
||||||
|
@field_validator("summary")
|
||||||
|
@classmethod
|
||||||
|
def _nontrivial(cls, v: str) -> str:
|
||||||
|
return reject_trivial(v, field="summary", min_chars=_SUMMARY_MIN)
|
||||||
|
|
||||||
|
def render_markdown(self) -> str:
|
||||||
|
parts = [_section("Summary", self.summary)]
|
||||||
|
if self.changes:
|
||||||
|
parts.append(_section("Changes", _bullets(self.changes)))
|
||||||
|
if self.risks:
|
||||||
|
parts.append(_section("Risks", _bullets(self.risks)))
|
||||||
|
if self.follow_ups:
|
||||||
|
parts.append(_section("Follow-ups", _bullets(self.follow_ups)))
|
||||||
|
return _join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
class QaNote(_Content):
|
||||||
|
"""A QA review note — summary + per-criterion verdicts + outcome."""
|
||||||
|
|
||||||
|
summary: str
|
||||||
|
ac_verdicts: list[AcVerdict] = Field(default_factory=list)
|
||||||
|
verdict: Verdict
|
||||||
|
|
||||||
|
@field_validator("ac_verdicts", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _coerce(cls, v: Any) -> Any:
|
||||||
|
return coerce_to_list(v)
|
||||||
|
|
||||||
|
@field_validator("summary")
|
||||||
|
@classmethod
|
||||||
|
def _nontrivial(cls, v: str) -> str:
|
||||||
|
return reject_trivial(v, field="summary", min_chars=_SUMMARY_MIN)
|
||||||
|
|
||||||
|
@field_validator("verdict")
|
||||||
|
@classmethod
|
||||||
|
def _passed_or_failed(cls, v: Verdict) -> Verdict:
|
||||||
|
if v not in (Verdict.PASSED, Verdict.FAILED):
|
||||||
|
raise ValueError("QA verdict must be 'passed' or 'failed'")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("ac_verdicts")
|
||||||
|
@classmethod
|
||||||
|
def _nonempty(cls, v: list[AcVerdict]) -> list[AcVerdict]:
|
||||||
|
if not v:
|
||||||
|
raise ValueError("ac_verdicts must cover at least one acceptance criterion")
|
||||||
|
return v
|
||||||
|
|
||||||
|
def render_markdown(self) -> str:
|
||||||
|
parts = [_section("Summary", self.summary)]
|
||||||
|
marks = {"verified": "✅", "failed": "❌", "na": "—"}
|
||||||
|
rows = [
|
||||||
|
f"- {marks[a.status]} **{a.criterion}** — {a.how}" for a in self.ac_verdicts
|
||||||
|
]
|
||||||
|
parts.append("## Acceptance Criteria\n" + "\n".join(rows))
|
||||||
|
parts.append(_section("Verdict", self.verdict.value))
|
||||||
|
return _join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
class DocNote(_Content):
|
||||||
|
"""A documenter's note — what was documented vs deliberately skipped."""
|
||||||
|
|
||||||
|
summary: str
|
||||||
|
documented: list[str] = Field(default_factory=list)
|
||||||
|
skipped: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
@field_validator("documented", "skipped", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _coerce(cls, v: Any) -> Any:
|
||||||
|
return coerce_to_list(v)
|
||||||
|
|
||||||
|
@field_validator("summary")
|
||||||
|
@classmethod
|
||||||
|
def _nontrivial(cls, v: str) -> str:
|
||||||
|
return reject_trivial(v, field="summary", min_chars=_SUMMARY_MIN)
|
||||||
|
|
||||||
|
def render_markdown(self) -> str:
|
||||||
|
parts = [_section("Summary", self.summary)]
|
||||||
|
if self.documented:
|
||||||
|
parts.append(_section("Documented", _bullets(self.documented)))
|
||||||
|
if self.skipped:
|
||||||
|
parts.append(_section("Skipped", _bullets(self.skipped)))
|
||||||
|
return _join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditorNote(_Content):
|
||||||
|
"""An auditor's confidential observation."""
|
||||||
|
|
||||||
|
summary: str
|
||||||
|
concerns: list[str] = Field(default_factory=list)
|
||||||
|
severity: Literal["info", "watch", "risk"]
|
||||||
|
|
||||||
|
@field_validator("concerns", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _coerce(cls, v: Any) -> Any:
|
||||||
|
return coerce_to_list(v)
|
||||||
|
|
||||||
|
@field_validator("summary")
|
||||||
|
@classmethod
|
||||||
|
def _nontrivial(cls, v: str) -> str:
|
||||||
|
return reject_trivial(v, field="summary", min_chars=_SUMMARY_MIN)
|
||||||
|
|
||||||
|
def render_markdown(self) -> str:
|
||||||
|
parts = [
|
||||||
|
_section("Summary", self.summary),
|
||||||
|
_section("Severity", self.severity),
|
||||||
|
]
|
||||||
|
if self.concerns:
|
||||||
|
parts.append(_section("Concerns", _bullets(self.concerns)))
|
||||||
|
return _join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Registry + single validation entry point
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
CONTENT_MODELS: dict[str, type[_Content]] = {
|
||||||
|
"pr_review": PrReviewContent,
|
||||||
|
"task_description": TaskDescription,
|
||||||
|
"resumption": ResumptionNote,
|
||||||
|
"developer": DeveloperNote,
|
||||||
|
"qa": QaNote,
|
||||||
|
"doc": DocNote,
|
||||||
|
"auditor": AuditorNote,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_content(content_type: str, payload: Any) -> _Content:
|
||||||
|
"""Validate ``payload`` against the model for ``content_type``.
|
||||||
|
|
||||||
|
Returns the validated model (passed through unchanged if it is already an
|
||||||
|
instance of the right model). Raises ``ContentValidationError(field,
|
||||||
|
reason)`` — built from Pydantic's first error — on any failure, including an
|
||||||
|
unknown content type.
|
||||||
|
"""
|
||||||
|
model_cls = CONTENT_MODELS.get(content_type)
|
||||||
|
if model_cls is None:
|
||||||
|
raise ContentValidationError(
|
||||||
|
"content_type", f"unknown content type: {content_type!r}"
|
||||||
|
)
|
||||||
|
if isinstance(payload, model_cls):
|
||||||
|
return payload
|
||||||
|
try:
|
||||||
|
return model_cls.model_validate(
|
||||||
|
payload if isinstance(payload, dict) else dict(payload or {})
|
||||||
|
)
|
||||||
|
except ValidationError as exc:
|
||||||
|
first = exc.errors()[0]
|
||||||
|
loc = ".".join(str(p) for p in first.get("loc", ())) or "?"
|
||||||
|
raise ContentValidationError(loc, first.get("msg", "invalid")) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def required_shape(content_type: str) -> dict[str, str]:
|
||||||
|
"""A ``{field: type-hint}`` map for a content type, for remediation hints."""
|
||||||
|
model_cls = CONTENT_MODELS.get(content_type)
|
||||||
|
if model_cls is None:
|
||||||
|
return {}
|
||||||
|
shape: dict[str, str] = {}
|
||||||
|
for name, field in model_cls.model_fields.items():
|
||||||
|
ann = field.annotation
|
||||||
|
shape[name] = getattr(ann, "__name__", str(ann))
|
||||||
|
return shape
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Shared validation primitives for structured agent content.
|
||||||
|
|
||||||
|
Two jobs:
|
||||||
|
|
||||||
|
- ``reject_trivial`` — the non-empty / non-placeholder gate, reused by every
|
||||||
|
content model's field validators. Raises ``ValueError`` so it composes with
|
||||||
|
Pydantic field validators (which collect ``ValueError`` into a
|
||||||
|
``ValidationError``).
|
||||||
|
- ``ContentValidationError`` — the public, gateway-facing exception. The
|
||||||
|
top-level ``validate_content`` (in :mod:`.models`) converts Pydantic's
|
||||||
|
``ValidationError`` into this so callers get a single ``(field, reason)``
|
||||||
|
shape to build a remediation envelope from.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Placeholder tokens that are never an acceptable whole-field value. Extends the
|
||||||
|
# commit-validator's banned single-word list (services/gateway/commit_validator).
|
||||||
|
BANNED_PHRASES: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"wip",
|
||||||
|
"tmp",
|
||||||
|
"tbd",
|
||||||
|
"todo",
|
||||||
|
"asdf",
|
||||||
|
"oops",
|
||||||
|
"stuff",
|
||||||
|
"things",
|
||||||
|
"n/a",
|
||||||
|
"na",
|
||||||
|
"none",
|
||||||
|
"null",
|
||||||
|
"-",
|
||||||
|
"--",
|
||||||
|
"...",
|
||||||
|
".",
|
||||||
|
"x",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ContentValidationError(Exception):
|
||||||
|
"""A structured-content payload failed validation.
|
||||||
|
|
||||||
|
Carries the offending ``field`` and a human ``reason`` so the gateway can
|
||||||
|
return a remediable envelope (``{error, message, remediate, missing}``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, field: str, reason: str) -> None:
|
||||||
|
self.field = field
|
||||||
|
self.reason = reason
|
||||||
|
super().__init__(f"{field}: {reason}")
|
||||||
|
|
||||||
|
|
||||||
|
def reject_trivial(value: str, *, field: str, min_chars: int = 1) -> str:
|
||||||
|
"""Return the trimmed value, or raise ``ValueError`` if it is trivial.
|
||||||
|
|
||||||
|
Trivial = empty, shorter than ``min_chars``, or a known placeholder token.
|
||||||
|
Raises ``ValueError`` (not ``ContentValidationError``) so it can be used
|
||||||
|
directly inside Pydantic field validators.
|
||||||
|
"""
|
||||||
|
text = (value or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError(f"{field} must not be empty")
|
||||||
|
if len(text) < min_chars:
|
||||||
|
raise ValueError(f"{field} must be at least {min_chars} characters")
|
||||||
|
if text.lower() in BANNED_PHRASES:
|
||||||
|
raise ValueError(f"{field} must not be placeholder text (got {value!r})")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_to_list(value: Any) -> Any:
|
||||||
|
"""Wrap a lone scalar/dict into a one-element list; pass lists/None through.
|
||||||
|
|
||||||
|
Mirrors ``api.schemas.v1.do._coerce_to_list``: an agent that passes a single
|
||||||
|
string (or dict) where a list is declared is making the well-intentioned
|
||||||
|
single-item mistake — wrap it rather than reject it.
|
||||||
|
"""
|
||||||
|
if value is None or isinstance(value, list):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str | dict):
|
||||||
|
return [value]
|
||||||
|
return value
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""Validation tests for the structured content schema models."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from roboco.foundation.policy.content import (
|
||||||
|
AuditorNote,
|
||||||
|
ContentValidationError,
|
||||||
|
DeveloperNote,
|
||||||
|
DocNote,
|
||||||
|
PrReviewContent,
|
||||||
|
QaNote,
|
||||||
|
ResumptionNote,
|
||||||
|
TaskDescription,
|
||||||
|
validate_content,
|
||||||
|
)
|
||||||
|
from roboco.foundation.policy.content.enums import Severity, Verdict
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Valid construction
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_pr_review_valid() -> None:
|
||||||
|
c = PrReviewContent(
|
||||||
|
summary="The change is correct and covered by tests.",
|
||||||
|
findings=[
|
||||||
|
{
|
||||||
|
"file": "roboco/services/git.py",
|
||||||
|
"line": 42,
|
||||||
|
"severity": "major",
|
||||||
|
"expected": "raises on 422",
|
||||||
|
"actual": "swallows the error",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
verdict="changes_requested",
|
||||||
|
)
|
||||||
|
assert c.findings[0].severity is Severity.MAJOR
|
||||||
|
assert c.verdict is Verdict.CHANGES_REQUESTED
|
||||||
|
|
||||||
|
|
||||||
|
def test_qa_valid() -> None:
|
||||||
|
c = QaNote(
|
||||||
|
summary="Reviewed all acceptance criteria against the diff.",
|
||||||
|
ac_verdicts=[
|
||||||
|
{"criterion": "AC1 returns 400", "status": "verified", "how": "test passes"}
|
||||||
|
],
|
||||||
|
verdict="passed",
|
||||||
|
)
|
||||||
|
assert c.ac_verdicts[0].status == "verified"
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_description_valid() -> None:
|
||||||
|
c = TaskDescription(
|
||||||
|
objective="Add a structured PR-review comment format.",
|
||||||
|
what_this_builds=["a reviewer schema"],
|
||||||
|
the_work=[
|
||||||
|
{
|
||||||
|
"team": "backend",
|
||||||
|
"summary": "schema + gateway",
|
||||||
|
"items": ["model", "verb"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
acceptance_criteria=["reviewer notes land in their own slot"],
|
||||||
|
)
|
||||||
|
assert c.the_work[0].team.value == "backend"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resumption_valid() -> None:
|
||||||
|
c = ResumptionNote(
|
||||||
|
done="schema landed", next="wire the gateway", where_to_look=["models.py"]
|
||||||
|
)
|
||||||
|
assert c.next == "wire the gateway"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Rejection
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_content_unknown_type() -> None:
|
||||||
|
with pytest.raises(ContentValidationError) as exc:
|
||||||
|
validate_content("nonsense", {})
|
||||||
|
assert exc.value.field == "content_type"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pr_review_missing_summary_rejected() -> None:
|
||||||
|
with pytest.raises(ContentValidationError) as exc:
|
||||||
|
validate_content("pr_review", {"verdict": "approved"})
|
||||||
|
assert exc.value.field == "summary"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pr_review_trivial_summary_rejected() -> None:
|
||||||
|
with pytest.raises(ContentValidationError):
|
||||||
|
validate_content("pr_review", {"summary": "wip", "verdict": "approved"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_pr_review_negative_verdict_requires_findings() -> None:
|
||||||
|
with pytest.raises(ContentValidationError):
|
||||||
|
validate_content(
|
||||||
|
"pr_review",
|
||||||
|
{
|
||||||
|
"summary": "Looks broken but no detail given here.",
|
||||||
|
"verdict": "failed",
|
||||||
|
"findings": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pr_review_approved_allows_empty_findings() -> None:
|
||||||
|
c = validate_content(
|
||||||
|
"pr_review",
|
||||||
|
{"summary": "All criteria met, nothing to change.", "verdict": "approved"},
|
||||||
|
)
|
||||||
|
assert isinstance(c, PrReviewContent)
|
||||||
|
|
||||||
|
|
||||||
|
def test_qa_verdict_must_be_pass_or_fail() -> None:
|
||||||
|
with pytest.raises(ContentValidationError) as exc:
|
||||||
|
validate_content(
|
||||||
|
"qa",
|
||||||
|
{
|
||||||
|
"summary": "Reviewed the acceptance criteria thoroughly.",
|
||||||
|
"ac_verdicts": [
|
||||||
|
{"criterion": "c", "status": "verified", "how": "test"}
|
||||||
|
],
|
||||||
|
"verdict": "approved",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert exc.value.field == "verdict"
|
||||||
|
|
||||||
|
|
||||||
|
def test_qa_requires_ac_verdicts() -> None:
|
||||||
|
with pytest.raises(ContentValidationError) as exc:
|
||||||
|
validate_content(
|
||||||
|
"qa",
|
||||||
|
{
|
||||||
|
"summary": "Reviewed everything carefully here.",
|
||||||
|
"ac_verdicts": [],
|
||||||
|
"verdict": "passed",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert exc.value.field == "ac_verdicts"
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_description_requires_work() -> None:
|
||||||
|
with pytest.raises(ContentValidationError) as exc:
|
||||||
|
validate_content(
|
||||||
|
"task_description",
|
||||||
|
{
|
||||||
|
"objective": "Build the thing properly.",
|
||||||
|
"the_work": [],
|
||||||
|
"acceptance_criteria": ["x"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert exc.value.field == "the_work"
|
||||||
|
|
||||||
|
|
||||||
|
def test_work_unit_rejects_non_cell_team() -> None:
|
||||||
|
with pytest.raises(ContentValidationError):
|
||||||
|
validate_content(
|
||||||
|
"task_description",
|
||||||
|
{
|
||||||
|
"objective": "Build the thing properly.",
|
||||||
|
"the_work": [{"team": "board", "summary": "do it", "items": ["a"]}],
|
||||||
|
"acceptance_criteria": ["x"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Graceful coercion (lone scalar -> one-element list)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_findings_single_dict_coerced_to_list() -> None:
|
||||||
|
c = validate_content(
|
||||||
|
"pr_review",
|
||||||
|
{
|
||||||
|
"summary": "One finding passed as a bare dict.",
|
||||||
|
"verdict": "changes_requested",
|
||||||
|
"findings": {
|
||||||
|
"file": "a.py",
|
||||||
|
"severity": "nit",
|
||||||
|
"expected": "trailing newline",
|
||||||
|
"actual": "no newline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert isinstance(c, PrReviewContent)
|
||||||
|
assert len(c.findings) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_where_to_look_single_string_coerced() -> None:
|
||||||
|
c = validate_content(
|
||||||
|
"resumption",
|
||||||
|
{
|
||||||
|
"done": "did the thing",
|
||||||
|
"next": "do next thing",
|
||||||
|
"where_to_look": "models.py",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert isinstance(c, ResumptionNote)
|
||||||
|
assert c.where_to_look == ["models.py"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_developer_and_doc_and_auditor_models() -> None:
|
||||||
|
assert isinstance(
|
||||||
|
validate_content("developer", {"summary": "Implemented the schema layer."}),
|
||||||
|
DeveloperNote,
|
||||||
|
)
|
||||||
|
assert isinstance(
|
||||||
|
validate_content("doc", {"summary": "Documented the new endpoints."}), DocNote
|
||||||
|
)
|
||||||
|
assert isinstance(
|
||||||
|
validate_content(
|
||||||
|
"auditor", {"summary": "No concerns with this change.", "severity": "info"}
|
||||||
|
),
|
||||||
|
AuditorNote,
|
||||||
|
)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Markdown rendering tests for the structured content models.
|
||||||
|
|
||||||
|
Assert structure (labeled sections + the content appears) rather than exact
|
||||||
|
bytes, so the renderers stay refactorable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from roboco.foundation.policy.content import (
|
||||||
|
PrReviewContent,
|
||||||
|
QaNote,
|
||||||
|
ResumptionNote,
|
||||||
|
TaskDescription,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pr_review_renders_sections_and_findings_table() -> None:
|
||||||
|
md = PrReviewContent(
|
||||||
|
summary="The guard is missing on the 422 path.",
|
||||||
|
findings=[
|
||||||
|
{
|
||||||
|
"file": "roboco/services/git.py",
|
||||||
|
"line": 42,
|
||||||
|
"severity": "blocker",
|
||||||
|
"expected": "retry as COMMENT",
|
||||||
|
"actual": "raises",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
verdict="changes_requested",
|
||||||
|
).render_markdown()
|
||||||
|
assert "## Summary" in md
|
||||||
|
assert "## Findings" in md
|
||||||
|
assert "| File | Line | Severity | Expected → Actual |" in md
|
||||||
|
assert "`roboco/services/git.py`" in md
|
||||||
|
assert "blocker" in md
|
||||||
|
assert "## Verdict" in md
|
||||||
|
assert "changes requested" in md
|
||||||
|
|
||||||
|
|
||||||
|
def test_qa_renders_checklist() -> None:
|
||||||
|
md = QaNote(
|
||||||
|
summary="Verified every acceptance criterion against the diff.",
|
||||||
|
ac_verdicts=[
|
||||||
|
{
|
||||||
|
"criterion": "AC1 returns 400",
|
||||||
|
"status": "verified",
|
||||||
|
"how": "test passes",
|
||||||
|
},
|
||||||
|
{"criterion": "AC2 logs", "status": "failed", "how": "no log emitted"},
|
||||||
|
],
|
||||||
|
verdict="failed",
|
||||||
|
).render_markdown()
|
||||||
|
assert "## Acceptance Criteria" in md
|
||||||
|
assert "✅" in md and "❌" in md
|
||||||
|
assert "AC1 returns 400" in md
|
||||||
|
assert "## Verdict" in md
|
||||||
|
|
||||||
|
|
||||||
|
def test_resumption_renders_done_next_where() -> None:
|
||||||
|
md = ResumptionNote(
|
||||||
|
done="schema module landed",
|
||||||
|
next="wire the gateway chokepoint",
|
||||||
|
where_to_look=[
|
||||||
|
"foundation/policy/content/models.py",
|
||||||
|
"services/content_notes.py",
|
||||||
|
],
|
||||||
|
).render_markdown()
|
||||||
|
assert "## Done" in md
|
||||||
|
assert "## Next" in md
|
||||||
|
assert "## Where to look" in md
|
||||||
|
assert "content_notes.py" in md
|
||||||
|
# No machine markers ever leak into the human resumption note.
|
||||||
|
assert "original_developer" not in md
|
||||||
|
assert "documenter:" not in md
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_description_renders_all_sections() -> None:
|
||||||
|
md = TaskDescription(
|
||||||
|
objective="Add the structured content standard.",
|
||||||
|
what_this_builds=["a schema layer", "a gateway gate"],
|
||||||
|
the_work=[
|
||||||
|
{"team": "backend", "summary": "models + verbs", "items": ["model", "verb"]}
|
||||||
|
],
|
||||||
|
notes=["reuse the Team enum"],
|
||||||
|
acceptance_criteria=["reviewer notes are isolated"],
|
||||||
|
).render_markdown()
|
||||||
|
assert "## Objective" in md
|
||||||
|
assert "## What This Builds" in md
|
||||||
|
assert "## The Work" in md
|
||||||
|
assert "**Backend**" in md
|
||||||
|
assert "## Notes" in md
|
||||||
|
assert "## Acceptance Criteria" in md
|
||||||
Reference in New Issue
Block a user