mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(docs): refuse doc_type=user_facing with roboco-website guidance (#301)
Phase 2 of the docs-site split: DocsService.write_doc only ever wrote into docs/<team>/... team buckets, which are excluded from the published site — so an agent reaching for write_doc to publish a user-facing page failed silently into an unpublished bucket. doc_type="user_facing" is now a recognized DocType member that DocsService refuses up front with guidance naming the roboco-website project and the 3-edit pattern (MDX + route wrapper + nav.ts entry), instead of the generic "Unknown doc_type" error. The roboco_docs_write MCP tool docstring and input-schema description are updated so documenter LLMs see the scope boundary before calling it. Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -24,6 +24,10 @@ class DocType(StrEnum):
|
|||||||
CHANGELOG = "changelog" # /docs/{team}/
|
CHANGELOG = "changelog" # /docs/{team}/
|
||||||
ARCHITECTURE = "architecture" # /docs/{team}/architecture/
|
ARCHITECTURE = "architecture" # /docs/{team}/architecture/
|
||||||
DESIGN = "design" # /docs/{team}/design/ (UX/UI)
|
DESIGN = "design" # /docs/{team}/design/ (UX/UI)
|
||||||
|
# Recognized but refused by DocsService.write_doc with actionable guidance
|
||||||
|
# (see REFUSED_DOC_TYPES) — this store's buckets never publish, so a
|
||||||
|
# user-facing page belongs in the roboco-website project instead.
|
||||||
|
USER_FACING = "user_facing"
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -175,6 +175,19 @@ def create_docs_mcp_server(agent_id: str) -> FastMCP:
|
|||||||
"""
|
"""
|
||||||
Write or update documentation for your current task.
|
Write or update documentation for your current task.
|
||||||
|
|
||||||
|
SCOPE — TEAM-FACING DOCS ONLY: this tool writes into docs/<team>/...
|
||||||
|
(api/qa/guide/readme/changelog/architecture/design), which is NEVER
|
||||||
|
published to the docs site. It is for internal notes future agents
|
||||||
|
need, not for anything a user should read.
|
||||||
|
|
||||||
|
User-facing docs (anything meant to ship at docs.roboco.tech) do NOT
|
||||||
|
go through this tool at all — author them as a normal task in the
|
||||||
|
'roboco-website' project instead: MDX under src/content/docs/, a
|
||||||
|
route wrapper under src/app/docs/, and a src/content/docs/nav.ts
|
||||||
|
entry (the 3-edit pattern; a CI check fails the PR if any of the
|
||||||
|
three is missing). Calling this tool with doc_type="user_facing" is
|
||||||
|
refused with this same guidance rather than silently accepted.
|
||||||
|
|
||||||
SMART DEDUPLICATION: Before creating a new doc, RAG searches for
|
SMART DEDUPLICATION: Before creating a new doc, RAG searches for
|
||||||
existing documentation with similar CONTENT (not just title).
|
existing documentation with similar CONTENT (not just title).
|
||||||
If a high-similarity match is found, the existing doc is updated
|
If a high-similarity match is found, the existing doc is updated
|
||||||
|
|||||||
@@ -21,7 +21,12 @@ class WriteDocInput(BaseModel):
|
|||||||
)
|
)
|
||||||
doc_type: str = Field(
|
doc_type: str = Field(
|
||||||
...,
|
...,
|
||||||
description="Type: api, qa, guide, readme, changelog, architecture, design",
|
description=(
|
||||||
|
"Type: api, qa, guide, readme, changelog, architecture, design. "
|
||||||
|
"These are team-facing docs only — never published. Do NOT pass "
|
||||||
|
"'user_facing': it is refused (see roboco_docs_write's docstring) "
|
||||||
|
"in favor of a documentation task on the roboco-website project."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
title: str = Field(
|
title: str = Field(
|
||||||
...,
|
...,
|
||||||
|
|||||||
+26
-1
@@ -72,6 +72,26 @@ TYPE_SUBFOLDERS: dict[str, str] = {
|
|||||||
"design": "design",
|
"design": "design",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# doc_type values that are recognized (not an "Unknown doc_type") but this
|
||||||
|
# store structurally cannot serve: it only writes team-facing docs into
|
||||||
|
# docs/<team>/... (all `exclude_docs`, never published — see the 2026-07-03
|
||||||
|
# docs-site-split spec, Phase 2). Checked before the TYPE_SUBFOLDERS lookup so
|
||||||
|
# an agent gets this actionable guidance instead of a generic error and
|
||||||
|
# instead of quietly reaching for a nearby valid type (e.g. "guide") that
|
||||||
|
# silently lands the write in an unpublished bucket.
|
||||||
|
REFUSED_DOC_TYPES: dict[str, str] = {
|
||||||
|
"user_facing": (
|
||||||
|
"doc_type='user_facing' is refused: this store only writes "
|
||||||
|
"team-facing docs into docs/<team>/... — excluded from the published "
|
||||||
|
"site. User-facing docs ship at docs.roboco.tech and are authored as "
|
||||||
|
"normal tasks in the 'roboco-website' project: MDX under "
|
||||||
|
"src/content/docs/, a route wrapper under src/app/docs/, and a "
|
||||||
|
"src/content/docs/nav.ts entry (the 3-edit pattern, PR-reviewed like "
|
||||||
|
"any other change). Open or claim a documentation task on "
|
||||||
|
"roboco-website instead of calling write_doc with this doc_type."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
# Roles that can write documentation
|
# Roles that can write documentation
|
||||||
WRITE_ROLES: frozenset[str] = frozenset({"documenter", "cell_pm"})
|
WRITE_ROLES: frozenset[str] = frozenset({"documenter", "cell_pm"})
|
||||||
|
|
||||||
@@ -200,7 +220,12 @@ class DocsService(BaseService):
|
|||||||
|
|
||||||
doc_type = req.doc_type
|
doc_type = req.doc_type
|
||||||
|
|
||||||
# 2. Validate doc_type
|
# 2. Validate doc_type — a recognized-but-refused type (e.g.
|
||||||
|
# "user_facing") short-circuits with actionable guidance before the
|
||||||
|
# generic "unknown type" branch below.
|
||||||
|
if doc_type in REFUSED_DOC_TYPES:
|
||||||
|
raise ValidationError(REFUSED_DOC_TYPES[doc_type], field="doc_type")
|
||||||
|
|
||||||
if doc_type not in TYPE_SUBFOLDERS:
|
if doc_type not in TYPE_SUBFOLDERS:
|
||||||
valid_types = list(TYPE_SUBFOLDERS.keys())
|
valid_types = list(TYPE_SUBFOLDERS.keys())
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
|
|||||||
@@ -92,6 +92,33 @@ async def test_write_doc_validation_error(docs_client: AsyncClient) -> None:
|
|||||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_write_doc_user_facing_refused_is_400_not_422(
|
||||||
|
docs_client: AsyncClient,
|
||||||
|
) -> None:
|
||||||
|
"""doc_type='user_facing' is a recognized DocType enum member, so Pydantic
|
||||||
|
accepts it at the HTTP boundary (no 422) and the service's actionable
|
||||||
|
refusal (400 with roboco-website guidance) is what the agent sees."""
|
||||||
|
with patch("roboco.api.routes.docs.get_docs_service") as mock_get:
|
||||||
|
mock_service = AsyncMock()
|
||||||
|
mock_service.write_doc = AsyncMock(
|
||||||
|
side_effect=ValidationError("...roboco-website project...")
|
||||||
|
)
|
||||||
|
mock_get.return_value = mock_service
|
||||||
|
response = await docs_client.post(
|
||||||
|
"/api/docs/write",
|
||||||
|
json={
|
||||||
|
"task_id": str(uuid4()),
|
||||||
|
"filename": "test.md",
|
||||||
|
"doc_type": "user_facing",
|
||||||
|
"title": "Test",
|
||||||
|
"content": "Some content",
|
||||||
|
},
|
||||||
|
headers=_HDR,
|
||||||
|
)
|
||||||
|
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_write_doc_unauthorized(docs_client: AsyncClient) -> None:
|
async def test_write_doc_unauthorized(docs_client: AsyncClient) -> None:
|
||||||
"""Service raises UnauthorizedError → 403."""
|
"""Service raises UnauthorizedError → 403."""
|
||||||
|
|||||||
@@ -160,6 +160,29 @@ async def test_write_doc_invalid_doc_type(docs_setup: dict) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_write_doc_user_facing_refused(docs_setup: dict) -> None:
|
||||||
|
"""doc_type='user_facing' is a recognized value (not a generic 'Unknown
|
||||||
|
doc_type') but is structurally refused: this store's buckets are all
|
||||||
|
excluded from the published site. The guidance names the roboco-website
|
||||||
|
project and the 3-edit pattern instead of silently landing an
|
||||||
|
unpublished write (docs-site-split Phase 2)."""
|
||||||
|
svc = docs_setup["svc"]
|
||||||
|
with pytest.raises(ValidationError, match="roboco-website") as exc_info:
|
||||||
|
await svc.write_doc(
|
||||||
|
agent_id="be-doc",
|
||||||
|
req=WriteDocInput(
|
||||||
|
task_id=docs_setup["task_id"],
|
||||||
|
filename="x.md",
|
||||||
|
doc_type="user_facing",
|
||||||
|
title="Title",
|
||||||
|
content="Content",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert "Unknown doc_type" not in str(exc_info.value)
|
||||||
|
assert "docs.roboco.tech" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_write_doc_path_traversal_in_filename(docs_setup: dict) -> None:
|
async def test_write_doc_path_traversal_in_filename(docs_setup: dict) -> None:
|
||||||
svc = docs_setup["svc"]
|
svc = docs_setup["svc"]
|
||||||
|
|||||||
Reference in New Issue
Block a user