state-hub/api/routers/suggestions.py

177 lines
5.9 KiB
Python
Raw Normal View History

import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.domain import Domain
from api.models.suggestion import (
OPEN_SUGGESTION_STAGES,
Suggestion,
SuggestionNote,
SuggestionStage,
)
from api.schemas.suggestion import (
SuggestionBumpRelevance,
SuggestionCreate,
SuggestionDecline,
SuggestionNoteRead,
SuggestionPromote,
SuggestionRead,
SuggestionVet,
)
from api.services.suggestion_wsjf import compute_wsjf, cost_of_delay, suggestion_sort_key
router = APIRouter(prefix="/suggestions", tags=["suggestions"])
CUST-WP-0061-T06: retire suggestions to read-only legacy Founder-reviewed decision (WorkOrchestrationArchitectureDraft.md v0.2 section 8 item 6): the fresh intake work-record entity replaces suggestions, not a rename-bridge. All 5 mutation endpoints (create, vet, decline, promote, bump-relevance) now 410 with a pointer to POST /intakes/ and the migration doc; GET/list stay live for the historical record (10 rows migrated to file-backed intake records in the-custodian, see that repo's intake-legacy-suggestions-migration.md and CUST-IN-0001 through CUST-IN-0010). Removed dead code the retirement makes unreachable: Task/TaskPriority/ TaskStatus/normalize_task_status imports (only used by the deleted promote body), the suggestion_relevance.bump_relevance import, and the _ALLOWED_*_FROM stage-guard sets + _reject_stage helper (only used by the deleted vet/decline/promote bodies). WSJF ranking (compute_wsjf, cost_of_delay, suggestion_sort_key) stays -- still exercised by the surviving GET /suggestions/?rank=wsjf read path. MCP tool docstrings (create_suggestion, vet_suggestion, decline_suggestion, promote_suggestion_to_task, bump_suggestion_relevance) updated to point at the replacement (create_intake/route_intake/close_intake) rather than silently 410ing with no guidance. tests/test_suggestions.py rewritten: verifies all 5 mutations 410, GET/list still work for historical rows (seeded directly via the DB session since creation is retired -- there's no other way to get historical data into the table anymore), 404 still behaves normally on unknown ids. Live-verified against the running dev API: POST 410s, GET with include_terminal=true still returns all 10 migrated-and-declined historical rows. No regressions: full repo suite green (563 tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 01:51:59 +02:00
def _retired() -> HTTPException:
"""CUST-WP-0061-T06 (2026-07-21): suggestions are read-only legacy.
Reads (GET) stay live for the historical record; every mutation is
retired. Use the intake work-record entity instead
(canon/standards/work-record-types_v0.1.md)."""
return HTTPException(
status_code=410,
detail=(
"suggestions are retired (read-only legacy, CUST-WP-0061-T06). "
"Use POST /intakes/ instead — see canon/standards/"
"work-record-types_v0.1.md and the-custodian/"
"intake-legacy-suggestions-migration.md."
),
)
async def _resolve_domain_id(slug: str, session: AsyncSession) -> uuid.UUID:
row = await session.execute(
select(Domain.id).where(Domain.slug == slug, Domain.status == "active")
)
domain_id = row.scalar_one_or_none()
if domain_id is None:
valid = [r[0] for r in (await session.execute(
select(Domain.slug).where(Domain.status == "active")
)).all()]
raise HTTPException(
status_code=422,
detail=f"Unknown domain '{slug}'. Valid domains: {sorted(valid)}",
)
return domain_id
def _enrich_read(suggestion: Suggestion) -> SuggestionRead:
data = SuggestionRead.model_validate(suggestion)
data.cost_of_delay = cost_of_delay(suggestion)
data.wsjf = compute_wsjf(suggestion)
return data
async def _get_suggestion_or_404(
suggestion_id: uuid.UUID,
session: AsyncSession,
) -> Suggestion:
suggestion = await session.get(Suggestion, suggestion_id)
if suggestion is None:
raise HTTPException(status_code=404, detail="Suggestion not found")
return suggestion
@router.get("/", response_model=list[SuggestionRead])
async def list_suggestions(
domain: str | None = None,
stage: SuggestionStage | None = None,
include_terminal: bool = Query(False),
rank: str | None = Query(None),
limit: int = Query(100, ge=1, le=500),
session: AsyncSession = Depends(get_session),
) -> list[SuggestionRead]:
q = select(Suggestion)
if domain:
domain_id = await _resolve_domain_id(domain, session)
q = q.where(Suggestion.domain_id == domain_id)
if stage:
q = q.where(Suggestion.stage == stage)
elif not include_terminal:
q = q.where(Suggestion.stage.in_(OPEN_SUGGESTION_STAGES))
result = await session.execute(q)
suggestions = list(result.scalars().all())
if rank == "wsjf":
suggestions.sort(key=suggestion_sort_key)
else:
suggestions.sort(key=lambda s: s.created_at)
return [_enrich_read(s) for s in suggestions[:limit]]
@router.post("/", response_model=SuggestionRead, status_code=status.HTTP_201_CREATED)
async def create_suggestion(
body: SuggestionCreate,
session: AsyncSession = Depends(get_session),
) -> SuggestionRead:
CUST-WP-0061-T06: retire suggestions to read-only legacy Founder-reviewed decision (WorkOrchestrationArchitectureDraft.md v0.2 section 8 item 6): the fresh intake work-record entity replaces suggestions, not a rename-bridge. All 5 mutation endpoints (create, vet, decline, promote, bump-relevance) now 410 with a pointer to POST /intakes/ and the migration doc; GET/list stay live for the historical record (10 rows migrated to file-backed intake records in the-custodian, see that repo's intake-legacy-suggestions-migration.md and CUST-IN-0001 through CUST-IN-0010). Removed dead code the retirement makes unreachable: Task/TaskPriority/ TaskStatus/normalize_task_status imports (only used by the deleted promote body), the suggestion_relevance.bump_relevance import, and the _ALLOWED_*_FROM stage-guard sets + _reject_stage helper (only used by the deleted vet/decline/promote bodies). WSJF ranking (compute_wsjf, cost_of_delay, suggestion_sort_key) stays -- still exercised by the surviving GET /suggestions/?rank=wsjf read path. MCP tool docstrings (create_suggestion, vet_suggestion, decline_suggestion, promote_suggestion_to_task, bump_suggestion_relevance) updated to point at the replacement (create_intake/route_intake/close_intake) rather than silently 410ing with no guidance. tests/test_suggestions.py rewritten: verifies all 5 mutations 410, GET/list still work for historical rows (seeded directly via the DB session since creation is retired -- there's no other way to get historical data into the table anymore), 404 still behaves normally on unknown ids. Live-verified against the running dev API: POST 410s, GET with include_terminal=true still returns all 10 migrated-and-declined historical rows. No regressions: full repo suite green (563 tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 01:51:59 +02:00
"""Retired (CUST-WP-0061-T06, 2026-07-21): suggestions are read-only
legacy. Use `POST /intakes/` (kind: intake per
canon/standards/work-record-types_v0.1.md) instead a fresh entity,
not a rename of this table. The 10 records that were still open at
retirement were migrated to file-backed intake records; see
the-custodian/intake-legacy-suggestions-migration.md."""
raise _retired()
@router.get("/{suggestion_id}", response_model=SuggestionRead)
async def get_suggestion(
suggestion_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> SuggestionRead:
suggestion = await _get_suggestion_or_404(suggestion_id, session)
return _enrich_read(suggestion)
@router.post("/{suggestion_id}/vet", response_model=SuggestionRead)
async def vet_suggestion(
suggestion_id: uuid.UUID,
body: SuggestionVet,
session: AsyncSession = Depends(get_session),
) -> SuggestionRead:
CUST-WP-0061-T06: retire suggestions to read-only legacy Founder-reviewed decision (WorkOrchestrationArchitectureDraft.md v0.2 section 8 item 6): the fresh intake work-record entity replaces suggestions, not a rename-bridge. All 5 mutation endpoints (create, vet, decline, promote, bump-relevance) now 410 with a pointer to POST /intakes/ and the migration doc; GET/list stay live for the historical record (10 rows migrated to file-backed intake records in the-custodian, see that repo's intake-legacy-suggestions-migration.md and CUST-IN-0001 through CUST-IN-0010). Removed dead code the retirement makes unreachable: Task/TaskPriority/ TaskStatus/normalize_task_status imports (only used by the deleted promote body), the suggestion_relevance.bump_relevance import, and the _ALLOWED_*_FROM stage-guard sets + _reject_stage helper (only used by the deleted vet/decline/promote bodies). WSJF ranking (compute_wsjf, cost_of_delay, suggestion_sort_key) stays -- still exercised by the surviving GET /suggestions/?rank=wsjf read path. MCP tool docstrings (create_suggestion, vet_suggestion, decline_suggestion, promote_suggestion_to_task, bump_suggestion_relevance) updated to point at the replacement (create_intake/route_intake/close_intake) rather than silently 410ing with no guidance. tests/test_suggestions.py rewritten: verifies all 5 mutations 410, GET/list still work for historical rows (seeded directly via the DB session since creation is retired -- there's no other way to get historical data into the table anymore), 404 still behaves normally on unknown ids. Live-verified against the running dev API: POST 410s, GET with include_terminal=true still returns all 10 migrated-and-declined historical rows. No regressions: full repo suite green (563 tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 01:51:59 +02:00
"""Retired (CUST-WP-0061-T06). See _retired()."""
raise _retired()
@router.post("/{suggestion_id}/decline", response_model=SuggestionRead)
async def decline_suggestion(
suggestion_id: uuid.UUID,
body: SuggestionDecline,
session: AsyncSession = Depends(get_session),
) -> SuggestionRead:
CUST-WP-0061-T06: retire suggestions to read-only legacy Founder-reviewed decision (WorkOrchestrationArchitectureDraft.md v0.2 section 8 item 6): the fresh intake work-record entity replaces suggestions, not a rename-bridge. All 5 mutation endpoints (create, vet, decline, promote, bump-relevance) now 410 with a pointer to POST /intakes/ and the migration doc; GET/list stay live for the historical record (10 rows migrated to file-backed intake records in the-custodian, see that repo's intake-legacy-suggestions-migration.md and CUST-IN-0001 through CUST-IN-0010). Removed dead code the retirement makes unreachable: Task/TaskPriority/ TaskStatus/normalize_task_status imports (only used by the deleted promote body), the suggestion_relevance.bump_relevance import, and the _ALLOWED_*_FROM stage-guard sets + _reject_stage helper (only used by the deleted vet/decline/promote bodies). WSJF ranking (compute_wsjf, cost_of_delay, suggestion_sort_key) stays -- still exercised by the surviving GET /suggestions/?rank=wsjf read path. MCP tool docstrings (create_suggestion, vet_suggestion, decline_suggestion, promote_suggestion_to_task, bump_suggestion_relevance) updated to point at the replacement (create_intake/route_intake/close_intake) rather than silently 410ing with no guidance. tests/test_suggestions.py rewritten: verifies all 5 mutations 410, GET/list still work for historical rows (seeded directly via the DB session since creation is retired -- there's no other way to get historical data into the table anymore), 404 still behaves normally on unknown ids. Live-verified against the running dev API: POST 410s, GET with include_terminal=true still returns all 10 migrated-and-declined historical rows. No regressions: full repo suite green (563 tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 01:51:59 +02:00
"""Retired (CUST-WP-0061-T06). See _retired()."""
raise _retired()
@router.post("/{suggestion_id}/promote", response_model=SuggestionRead)
async def promote_suggestion_to_task(
suggestion_id: uuid.UUID,
body: SuggestionPromote,
session: AsyncSession = Depends(get_session),
) -> SuggestionRead:
CUST-WP-0061-T06: retire suggestions to read-only legacy Founder-reviewed decision (WorkOrchestrationArchitectureDraft.md v0.2 section 8 item 6): the fresh intake work-record entity replaces suggestions, not a rename-bridge. All 5 mutation endpoints (create, vet, decline, promote, bump-relevance) now 410 with a pointer to POST /intakes/ and the migration doc; GET/list stay live for the historical record (10 rows migrated to file-backed intake records in the-custodian, see that repo's intake-legacy-suggestions-migration.md and CUST-IN-0001 through CUST-IN-0010). Removed dead code the retirement makes unreachable: Task/TaskPriority/ TaskStatus/normalize_task_status imports (only used by the deleted promote body), the suggestion_relevance.bump_relevance import, and the _ALLOWED_*_FROM stage-guard sets + _reject_stage helper (only used by the deleted vet/decline/promote bodies). WSJF ranking (compute_wsjf, cost_of_delay, suggestion_sort_key) stays -- still exercised by the surviving GET /suggestions/?rank=wsjf read path. MCP tool docstrings (create_suggestion, vet_suggestion, decline_suggestion, promote_suggestion_to_task, bump_suggestion_relevance) updated to point at the replacement (create_intake/route_intake/close_intake) rather than silently 410ing with no guidance. tests/test_suggestions.py rewritten: verifies all 5 mutations 410, GET/list still work for historical rows (seeded directly via the DB session since creation is retired -- there's no other way to get historical data into the table anymore), 404 still behaves normally on unknown ids. Live-verified against the running dev API: POST 410s, GET with include_terminal=true still returns all 10 migrated-and-declined historical rows. No regressions: full repo suite green (563 tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 01:51:59 +02:00
"""Retired (CUST-WP-0061-T06). See _retired()."""
raise _retired()
@router.post("/{suggestion_id}/bump-relevance", response_model=SuggestionRead)
async def bump_suggestion_relevance(
suggestion_id: uuid.UUID,
body: SuggestionBumpRelevance,
session: AsyncSession = Depends(get_session),
) -> SuggestionRead:
CUST-WP-0061-T06: retire suggestions to read-only legacy Founder-reviewed decision (WorkOrchestrationArchitectureDraft.md v0.2 section 8 item 6): the fresh intake work-record entity replaces suggestions, not a rename-bridge. All 5 mutation endpoints (create, vet, decline, promote, bump-relevance) now 410 with a pointer to POST /intakes/ and the migration doc; GET/list stay live for the historical record (10 rows migrated to file-backed intake records in the-custodian, see that repo's intake-legacy-suggestions-migration.md and CUST-IN-0001 through CUST-IN-0010). Removed dead code the retirement makes unreachable: Task/TaskPriority/ TaskStatus/normalize_task_status imports (only used by the deleted promote body), the suggestion_relevance.bump_relevance import, and the _ALLOWED_*_FROM stage-guard sets + _reject_stage helper (only used by the deleted vet/decline/promote bodies). WSJF ranking (compute_wsjf, cost_of_delay, suggestion_sort_key) stays -- still exercised by the surviving GET /suggestions/?rank=wsjf read path. MCP tool docstrings (create_suggestion, vet_suggestion, decline_suggestion, promote_suggestion_to_task, bump_suggestion_relevance) updated to point at the replacement (create_intake/route_intake/close_intake) rather than silently 410ing with no guidance. tests/test_suggestions.py rewritten: verifies all 5 mutations 410, GET/list still work for historical rows (seeded directly via the DB session since creation is retired -- there's no other way to get historical data into the table anymore), 404 still behaves normally on unknown ids. Live-verified against the running dev API: POST 410s, GET with include_terminal=true still returns all 10 migrated-and-declined historical rows. No regressions: full repo suite green (563 tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 01:51:59 +02:00
"""Retired (CUST-WP-0061-T06). See _retired()."""
raise _retired()
@router.get("/{suggestion_id}/notes", response_model=list[SuggestionNoteRead])
async def list_suggestion_notes(
suggestion_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> list[SuggestionNote]:
await _get_suggestion_or_404(suggestion_id, session)
result = await session.execute(
select(SuggestionNote)
.where(SuggestionNote.suggestion_id == suggestion_id)
.order_by(SuggestionNote.created_at)
)
return list(result.scalars().all())