generated from coulomb/repo-seed
Finish CUST-WP-0011 and implement STATE-WP-0061 suggestion backlog.
Add persisted Suggestion entities with WSJF ranking, relevance bumps, REST/MCP write surfaces, /suggestions dashboard page, and daily triage digest integration. Document the cluster operating model, archive the completed migration workplan, and seed WARDEN-WP-0012 routing scenarios.
This commit is contained in:
@@ -12,7 +12,7 @@ from starlette.responses import Response as StarletteResponse
|
||||
from api.database import engine
|
||||
from api.events import shutdown_publisher
|
||||
from api.services.write_idempotency import WriteIdempotencyMiddleware
|
||||
from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies
|
||||
from api.routers import decisions, extension_points, progress, state, suggestions, tasks, technical_debt, topics, workstreams, workstream_dependencies
|
||||
from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages, capability_requests, tpsc, services
|
||||
from api.routers import token_events
|
||||
from api.routers import interface_changes
|
||||
@@ -123,6 +123,7 @@ app.include_router(contributions.router)
|
||||
app.include_router(sbom.router)
|
||||
app.include_router(messages.router)
|
||||
app.include_router(capability_requests.router)
|
||||
app.include_router(suggestions.router)
|
||||
app.include_router(tpsc.router)
|
||||
app.include_router(services.router)
|
||||
app.include_router(token_events.router)
|
||||
|
||||
@@ -34,6 +34,12 @@ from api.models.workplan_launch_request import WorkplanLaunchRequest
|
||||
from api.models.fabric_graph import FabricGraphImport, FabricGraphNode, FabricGraphEdge
|
||||
from api.models.legacy_meter import LegacyInterface, LegacyInterfaceUsageBucket
|
||||
from api.models.write_idempotency_key import WriteIdempotencyKey
|
||||
from api.models.suggestion import (
|
||||
Suggestion,
|
||||
SuggestionNote,
|
||||
SuggestionRelevanceBump,
|
||||
SuggestionStage,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
@@ -67,4 +73,5 @@ __all__ = [
|
||||
"FabricGraphImport", "FabricGraphNode", "FabricGraphEdge",
|
||||
"LegacyInterface", "LegacyInterfaceUsageBucket",
|
||||
"WriteIdempotencyKey",
|
||||
"Suggestion", "SuggestionNote", "SuggestionRelevanceBump", "SuggestionStage",
|
||||
]
|
||||
120
api/models/suggestion.py
Normal file
120
api/models/suggestion.py
Normal file
@@ -0,0 +1,120 @@
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Enum, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class SuggestionStage(str, enum.Enum):
|
||||
suggestion = "suggestion"
|
||||
requirement = "requirement"
|
||||
promoted = "promoted"
|
||||
declined = "declined"
|
||||
|
||||
|
||||
OPEN_SUGGESTION_STAGES = (SuggestionStage.suggestion, SuggestionStage.requirement)
|
||||
|
||||
|
||||
class Suggestion(Base, TimestampMixin):
|
||||
__tablename__ = "suggestions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
domain_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("domains.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
origin: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
origin_ref: Mapped[str | None] = mapped_column(String(200), nullable=True, index=True)
|
||||
stage: Mapped[SuggestionStage] = mapped_column(
|
||||
Enum(SuggestionStage, name="suggestionstage"),
|
||||
nullable=False,
|
||||
default=SuggestionStage.suggestion,
|
||||
index=True,
|
||||
)
|
||||
relevance: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
relevance_events: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, server_default="0"
|
||||
)
|
||||
last_requested_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
base_value: Mapped[float] = mapped_column(Float, nullable=False, default=3.0, server_default="3")
|
||||
job_size: Mapped[float] = mapped_column(Float, nullable=False, default=3.0, server_default="3")
|
||||
relevance_weight: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=1.0, server_default="1"
|
||||
)
|
||||
promoted_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
|
||||
topic: Mapped["Topic | None"] = relationship("Topic", lazy="selectin") # noqa: F821
|
||||
workplan: Mapped["Workplan | None"] = relationship("Workplan", lazy="selectin") # noqa: F821
|
||||
promoted_task: Mapped["Task | None"] = relationship("Task", lazy="selectin") # noqa: F821
|
||||
notes: Mapped[list["SuggestionNote"]] = relationship(
|
||||
"SuggestionNote",
|
||||
back_populates="suggestion",
|
||||
lazy="selectin",
|
||||
order_by="SuggestionNote.created_at",
|
||||
)
|
||||
|
||||
@property
|
||||
def domain_slug(self) -> str:
|
||||
return self.domain.slug if self.domain is not None else ""
|
||||
|
||||
|
||||
class SuggestionNote(Base):
|
||||
__tablename__ = "suggestion_notes"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||||
suggestion_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("suggestions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
stage: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
author: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
suggestion: Mapped["Suggestion"] = relationship("Suggestion", back_populates="notes")
|
||||
|
||||
|
||||
class SuggestionRelevanceBump(Base):
|
||||
"""Audit trail for relevance bumps; supports debounce lookups."""
|
||||
|
||||
__tablename__ = "suggestion_relevance_bumps"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||||
suggestion_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("suggestions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
source: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
source_key: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
@@ -13,6 +13,7 @@ from api.models.capability_request import CapabilityRequest
|
||||
from api.models.domain import Domain
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.task import Task
|
||||
from api.services.suggestion_relevance import bump_matching_for_capability_request
|
||||
from api.schemas.capability_request import (
|
||||
CapabilityRequestAccept,
|
||||
CapabilityRequestCreate,
|
||||
@@ -68,6 +69,23 @@ def _build_capability_request(
|
||||
)
|
||||
|
||||
|
||||
async def _on_capability_request_persisted(
|
||||
session: AsyncSession,
|
||||
req: CapabilityRequest,
|
||||
body: CapabilityRequestCreate,
|
||||
) -> None:
|
||||
await session.flush()
|
||||
await bump_matching_for_capability_request(
|
||||
session,
|
||||
request_id=req.id,
|
||||
title=body.title,
|
||||
description=body.description or "",
|
||||
capability_type=body.capability_type,
|
||||
catalog_entry_id=req.catalog_entry_id,
|
||||
)
|
||||
await _notify_on_create(session, req, body)
|
||||
|
||||
|
||||
async def _notify_on_create(
|
||||
session: AsyncSession,
|
||||
req: CapabilityRequest,
|
||||
@@ -405,7 +423,7 @@ router.include_router(
|
||||
request_read_schema=CapabilityRequestRead,
|
||||
route_request=_route_capability,
|
||||
build_request=_build_capability_request,
|
||||
on_request_persisted=_notify_on_create,
|
||||
on_request_persisted=_on_capability_request_persisted,
|
||||
check_transition=_check_transition,
|
||||
apply_accept_fields=_apply_accept_fields,
|
||||
after_accept=_notify_on_accept,
|
||||
|
||||
@@ -21,8 +21,12 @@ from api.models.sbom_snapshot import SBOMSnapshot
|
||||
from api.models.task import Task, TaskPriority, TaskStatus
|
||||
from api.models.technical_debt import TechnicalDebt
|
||||
from api.models.topic import Topic, TopicStatus
|
||||
from api.models.suggestion import OPEN_SUGGESTION_STAGES, Suggestion
|
||||
from api.models.workplan import Workplan
|
||||
from api.models.workplan_dependency import WorkplanDependency
|
||||
from api.schemas.suggestion import RankedSuggestionDigest
|
||||
from api.services.suggestion_relevance import bump_suggestions_for_next_steps
|
||||
from api.services.suggestion_wsjf import compute_wsjf, suggestion_sort_key
|
||||
from api.schemas.decision import DecisionRead
|
||||
from api.schemas.domain import DomainSummary
|
||||
from api.schemas.progress_event import ProgressEventRead
|
||||
@@ -329,7 +333,7 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
|
||||
),
|
||||
)
|
||||
|
||||
next_steps = await _derive_next_steps(session)
|
||||
next_steps, _ = await _derive_next_steps(session)
|
||||
|
||||
# Domain summary stats
|
||||
domain_summaries = await _build_domain_summaries(session)
|
||||
@@ -368,6 +372,8 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
ranked_suggestions = await _ranked_suggestion_digest(session, limit=10)
|
||||
|
||||
result = StateSummary(
|
||||
generated_at=datetime.now(tz=timezone.utc),
|
||||
totals=totals,
|
||||
@@ -387,6 +393,7 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
|
||||
contribution_counts=contribution_counts,
|
||||
licence_risk_count=licence_risk_count,
|
||||
open_capability_requests=open_cap_req_count,
|
||||
ranked_suggestions=ranked_suggestions,
|
||||
open_workstreams=[
|
||||
WorkstreamWithDeps(
|
||||
**{
|
||||
@@ -700,7 +707,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
||||
waiting_tasks=[TaskRead.model_validate(t) for t in waiting],
|
||||
blocked_tasks=[TaskRead.model_validate(t) for t in waiting],
|
||||
recent_progress=[ProgressEventRead.model_validate(e) for e in recent],
|
||||
next_steps=await _derive_next_steps(session),
|
||||
next_steps=(await _derive_next_steps(session))[0],
|
||||
contribution_counts=contribution_counts,
|
||||
licence_risk_count=licence_risk_count,
|
||||
open_capability_requests=open_cap_req_count,
|
||||
@@ -852,7 +859,33 @@ _PRIORITY_RANK = {
|
||||
}
|
||||
|
||||
|
||||
async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
async def _ranked_suggestion_digest(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
limit: int = 10,
|
||||
) -> list[RankedSuggestionDigest]:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Suggestion).where(Suggestion.stage.in_(OPEN_SUGGESTION_STAGES))
|
||||
)
|
||||
).scalars().all()
|
||||
ranked = sorted(rows, key=suggestion_sort_key)[:limit]
|
||||
return [
|
||||
RankedSuggestionDigest(
|
||||
id=s.id,
|
||||
title=s.title,
|
||||
stage=s.stage,
|
||||
domain_slug=s.domain_slug,
|
||||
origin_ref=s.origin_ref,
|
||||
relevance=s.relevance,
|
||||
wsjf=compute_wsjf(s),
|
||||
last_requested_at=s.last_requested_at,
|
||||
)
|
||||
for s in ranked
|
||||
]
|
||||
|
||||
|
||||
async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], list[Suggestion]]:
|
||||
"""Derive contextual next-action suggestions from current hub state.
|
||||
|
||||
Two signal sources:
|
||||
@@ -991,7 +1024,31 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
))
|
||||
seen_task_ids.add(task.id)
|
||||
|
||||
return steps
|
||||
# Signal 3: open demand-weighted suggestions (needed-but-unmet backlog)
|
||||
open_suggestions = sorted(
|
||||
(
|
||||
await session.execute(
|
||||
select(Suggestion).where(Suggestion.stage.in_(OPEN_SUGGESTION_STAGES))
|
||||
)
|
||||
).scalars().all(),
|
||||
key=suggestion_sort_key,
|
||||
)[:5]
|
||||
for suggestion in open_suggestions:
|
||||
steps.append(NextStep(
|
||||
type="open_suggestion",
|
||||
domain=suggestion.domain_slug,
|
||||
workstream_id=suggestion.workplan_id,
|
||||
workstream_title=None,
|
||||
workstream_slug=suggestion.origin_ref,
|
||||
task_id=None,
|
||||
task_title=None,
|
||||
message=(
|
||||
f"Gated need '{suggestion.title}' (stage={suggestion.stage.value}, "
|
||||
f"relevance={suggestion.relevance}) — vet or promote when unblocked"
|
||||
),
|
||||
))
|
||||
|
||||
return steps, open_suggestions
|
||||
|
||||
|
||||
async def _get_domain_slug_for_workstream(ws: Workplan | None, session: AsyncSession) -> str | None:
|
||||
@@ -1019,8 +1076,12 @@ async def get_next_steps(session: AsyncSession = Depends(get_session)) -> list[N
|
||||
Returns suggestions based on:
|
||||
- Recently resolved decisions → first open task in the same workstream
|
||||
- Workstreams whose every dependency workstream is now finished -> first todo task
|
||||
- Open demand-weighted suggestions (gated needs accruing relevance)
|
||||
"""
|
||||
return await _derive_next_steps(session)
|
||||
steps, surfaced = await _derive_next_steps(session)
|
||||
await bump_suggestions_for_next_steps(session, surfaced)
|
||||
await session.commit()
|
||||
return steps
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
|
||||
259
api/routers/suggestions.py
Normal file
259
api/routers/suggestions.py
Normal file
@@ -0,0 +1,259 @@
|
||||
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.models.task import Task, TaskPriority, TaskStatus
|
||||
from api.schemas.suggestion import (
|
||||
SuggestionBumpRelevance,
|
||||
SuggestionCreate,
|
||||
SuggestionDecline,
|
||||
SuggestionNoteRead,
|
||||
SuggestionPromote,
|
||||
SuggestionRead,
|
||||
SuggestionVet,
|
||||
)
|
||||
from api.services.suggestion_relevance import bump_relevance
|
||||
from api.services.suggestion_wsjf import compute_wsjf, cost_of_delay, suggestion_sort_key
|
||||
from api.task_status import normalize_task_status
|
||||
|
||||
router = APIRouter(prefix="/suggestions", tags=["suggestions"])
|
||||
|
||||
_ALLOWED_VET_FROM = {SuggestionStage.suggestion}
|
||||
_ALLOWED_DECLINE_FROM = {SuggestionStage.suggestion, SuggestionStage.requirement}
|
||||
_ALLOWED_PROMOTE_FROM = {SuggestionStage.requirement}
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _reject_stage(suggestion: Suggestion, allowed: set[SuggestionStage], action: str) -> None:
|
||||
if suggestion.stage not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Cannot {action} suggestion in stage '{suggestion.stage.value}'",
|
||||
)
|
||||
|
||||
|
||||
@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:
|
||||
domain_id = await _resolve_domain_id(body.domain, session)
|
||||
suggestion = Suggestion(
|
||||
domain_id=domain_id,
|
||||
topic_id=body.topic_id,
|
||||
workplan_id=body.workplan_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
origin=body.origin,
|
||||
origin_ref=body.origin_ref,
|
||||
base_value=body.base_value,
|
||||
job_size=body.job_size,
|
||||
relevance_weight=body.relevance_weight,
|
||||
)
|
||||
session.add(suggestion)
|
||||
await session.commit()
|
||||
await session.refresh(suggestion)
|
||||
return _enrich_read(suggestion)
|
||||
|
||||
|
||||
@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:
|
||||
suggestion = await _get_suggestion_or_404(suggestion_id, session)
|
||||
_reject_stage(suggestion, _ALLOWED_VET_FROM, "vet")
|
||||
suggestion.stage = SuggestionStage.requirement
|
||||
if body.base_value is not None:
|
||||
suggestion.base_value = body.base_value
|
||||
if body.job_size is not None:
|
||||
suggestion.job_size = body.job_size
|
||||
if body.relevance_weight is not None:
|
||||
suggestion.relevance_weight = body.relevance_weight
|
||||
if body.workplan_id is not None:
|
||||
suggestion.workplan_id = body.workplan_id
|
||||
session.add(
|
||||
SuggestionNote(
|
||||
suggestion_id=suggestion.id,
|
||||
stage=SuggestionStage.requirement.value,
|
||||
author=body.author,
|
||||
content=body.note,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(suggestion)
|
||||
return _enrich_read(suggestion)
|
||||
|
||||
|
||||
@router.post("/{suggestion_id}/decline", response_model=SuggestionRead)
|
||||
async def decline_suggestion(
|
||||
suggestion_id: uuid.UUID,
|
||||
body: SuggestionDecline,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SuggestionRead:
|
||||
suggestion = await _get_suggestion_or_404(suggestion_id, session)
|
||||
_reject_stage(suggestion, _ALLOWED_DECLINE_FROM, "decline")
|
||||
suggestion.stage = SuggestionStage.declined
|
||||
session.add(
|
||||
SuggestionNote(
|
||||
suggestion_id=suggestion.id,
|
||||
stage=SuggestionStage.declined.value,
|
||||
author=body.author,
|
||||
content=body.note,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(suggestion)
|
||||
return _enrich_read(suggestion)
|
||||
|
||||
|
||||
@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:
|
||||
suggestion = await _get_suggestion_or_404(suggestion_id, session)
|
||||
_reject_stage(suggestion, _ALLOWED_PROMOTE_FROM, "promote")
|
||||
if suggestion.workplan_id is None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Suggestion must have workplan_id before promotion",
|
||||
)
|
||||
|
||||
task = Task(
|
||||
workplan_id=suggestion.workplan_id,
|
||||
title=body.task_title or suggestion.title,
|
||||
description=body.task_description or suggestion.description,
|
||||
status=TaskStatus(normalize_task_status(body.task_status)),
|
||||
priority=TaskPriority(body.task_priority),
|
||||
)
|
||||
session.add(task)
|
||||
await session.flush()
|
||||
|
||||
suggestion.stage = SuggestionStage.promoted
|
||||
suggestion.promoted_task_id = task.id
|
||||
if body.note:
|
||||
session.add(
|
||||
SuggestionNote(
|
||||
suggestion_id=suggestion.id,
|
||||
stage=SuggestionStage.promoted.value,
|
||||
author=body.author,
|
||||
content=body.note,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(suggestion)
|
||||
return _enrich_read(suggestion)
|
||||
|
||||
|
||||
@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:
|
||||
suggestion = await _get_suggestion_or_404(suggestion_id, session)
|
||||
source_key = body.author or "explicit"
|
||||
await bump_relevance(
|
||||
session,
|
||||
suggestion,
|
||||
source="explicit",
|
||||
source_key=source_key,
|
||||
reason=body.reason,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(suggestion)
|
||||
return _enrich_read(suggestion)
|
||||
|
||||
|
||||
@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())
|
||||
@@ -9,6 +9,7 @@ from api.schemas.domain import DomainSummary
|
||||
from api.schemas.progress_event import ProgressEventRead
|
||||
from api.schemas.task import TaskRead
|
||||
from api.schemas.topic import TopicWithWorkstreams
|
||||
from api.schemas.suggestion import RankedSuggestionDigest
|
||||
from api.schemas.workstream import WorkstreamWithDeps
|
||||
|
||||
|
||||
@@ -85,6 +86,7 @@ class StateSummary(BaseModel):
|
||||
contribution_counts: dict[str, int] = {}
|
||||
licence_risk_count: int = 0
|
||||
open_capability_requests: int = 0
|
||||
ranked_suggestions: list[RankedSuggestionDigest] = []
|
||||
|
||||
|
||||
class DashboardWorkplanRow(BaseModel):
|
||||
|
||||
96
api/schemas/suggestion.py
Normal file
96
api/schemas/suggestion.py
Normal file
@@ -0,0 +1,96 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from api.models.suggestion import SuggestionStage
|
||||
|
||||
|
||||
class SuggestionNoteRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
suggestion_id: uuid.UUID
|
||||
stage: str
|
||||
author: str | None = None
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class SuggestionRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
domain_id: uuid.UUID
|
||||
domain_slug: str = ""
|
||||
topic_id: uuid.UUID | None = None
|
||||
workplan_id: uuid.UUID | None = None
|
||||
title: str
|
||||
description: str | None = None
|
||||
origin: str | None = None
|
||||
origin_ref: str | None = None
|
||||
stage: SuggestionStage
|
||||
relevance: int
|
||||
relevance_events: int
|
||||
last_requested_at: datetime | None = None
|
||||
base_value: float
|
||||
job_size: float
|
||||
relevance_weight: float
|
||||
promoted_task_id: uuid.UUID | None = None
|
||||
cost_of_delay: float | None = None
|
||||
wsjf: float | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
notes: list[SuggestionNoteRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SuggestionCreate(BaseModel):
|
||||
domain: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
topic_id: uuid.UUID | None = None
|
||||
workplan_id: uuid.UUID | None = None
|
||||
origin: str | None = None
|
||||
origin_ref: str | None = None
|
||||
base_value: float = 3.0
|
||||
job_size: float = 3.0
|
||||
relevance_weight: float = 1.0
|
||||
|
||||
|
||||
class SuggestionVet(BaseModel):
|
||||
author: str | None = None
|
||||
note: str
|
||||
base_value: float | None = None
|
||||
job_size: float | None = None
|
||||
relevance_weight: float | None = None
|
||||
workplan_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class SuggestionDecline(BaseModel):
|
||||
author: str | None = None
|
||||
note: str
|
||||
|
||||
|
||||
class SuggestionPromote(BaseModel):
|
||||
author: str | None = None
|
||||
note: str | None = None
|
||||
task_title: str | None = None
|
||||
task_description: str | None = None
|
||||
task_priority: str = "medium"
|
||||
task_status: str = "wait"
|
||||
|
||||
|
||||
class SuggestionBumpRelevance(BaseModel):
|
||||
reason: str | None = None
|
||||
author: str | None = None
|
||||
|
||||
|
||||
class RankedSuggestionDigest(BaseModel):
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
stage: SuggestionStage
|
||||
domain_slug: str
|
||||
origin_ref: str | None = None
|
||||
relevance: int
|
||||
wsjf: float
|
||||
last_requested_at: datetime | None = None
|
||||
118
api/services/suggestion_relevance.py
Normal file
118
api/services/suggestion_relevance.py
Normal file
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.models.suggestion import (
|
||||
OPEN_SUGGESTION_STAGES,
|
||||
Suggestion,
|
||||
SuggestionRelevanceBump,
|
||||
SuggestionStage,
|
||||
)
|
||||
|
||||
DEBOUNCE_SECONDS = 3600
|
||||
|
||||
|
||||
async def bump_relevance(
|
||||
session: AsyncSession,
|
||||
suggestion: Suggestion,
|
||||
*,
|
||||
source: str,
|
||||
source_key: str,
|
||||
reason: str | None = None,
|
||||
) -> bool:
|
||||
"""Increment relevance when a suggestion is needed-but-unmet.
|
||||
|
||||
Returns True when the counter was incremented, False when debounced.
|
||||
"""
|
||||
if suggestion.stage not in OPEN_SUGGESTION_STAGES:
|
||||
return False
|
||||
|
||||
cutoff = datetime.now(tz=timezone.utc) - timedelta(seconds=DEBOUNCE_SECONDS)
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(SuggestionRelevanceBump.id)
|
||||
.where(SuggestionRelevanceBump.suggestion_id == suggestion.id)
|
||||
.where(SuggestionRelevanceBump.source == source)
|
||||
.where(SuggestionRelevanceBump.source_key == source_key)
|
||||
.where(SuggestionRelevanceBump.created_at >= cutoff)
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return False
|
||||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
suggestion.relevance += 1
|
||||
suggestion.relevance_events += 1
|
||||
suggestion.last_requested_at = now
|
||||
session.add(
|
||||
SuggestionRelevanceBump(
|
||||
suggestion_id=suggestion.id,
|
||||
source=source,
|
||||
source_key=source_key,
|
||||
reason=reason,
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def bump_matching_for_capability_request(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
request_id: uuid.UUID,
|
||||
title: str,
|
||||
description: str,
|
||||
capability_type: str,
|
||||
catalog_entry_id: uuid.UUID | None = None,
|
||||
) -> int:
|
||||
"""Bump open suggestions that match an unfulfilled capability need."""
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Suggestion).where(Suggestion.stage.in_(OPEN_SUGGESTION_STAGES))
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
haystack = f"{title} {description} {capability_type}".lower()
|
||||
bumped = 0
|
||||
for suggestion in rows:
|
||||
matched = False
|
||||
if suggestion.origin_ref:
|
||||
ref = suggestion.origin_ref.lower()
|
||||
if ref in haystack or any(token in haystack for token in ref.split("-") if len(token) > 4):
|
||||
matched = True
|
||||
if not matched and suggestion.title.lower() in haystack:
|
||||
matched = True
|
||||
if not matched:
|
||||
continue
|
||||
if await bump_relevance(
|
||||
session,
|
||||
suggestion,
|
||||
source="capability_request",
|
||||
source_key=str(request_id),
|
||||
reason=f"Capability request matched: {title}",
|
||||
):
|
||||
bumped += 1
|
||||
return bumped
|
||||
|
||||
|
||||
async def bump_suggestions_for_next_steps(
|
||||
session: AsyncSession,
|
||||
suggestions: list[Suggestion],
|
||||
) -> int:
|
||||
"""Bump only suggestions surfaced in a next-steps response."""
|
||||
bucket = datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H")
|
||||
bumped = 0
|
||||
for suggestion in suggestions:
|
||||
if await bump_relevance(
|
||||
session,
|
||||
suggestion,
|
||||
source="next_steps",
|
||||
source_key=bucket,
|
||||
reason="Surfaced during get_next_steps lookup",
|
||||
):
|
||||
bumped += 1
|
||||
return bumped
|
||||
17
api/services/suggestion_wsjf.py
Normal file
17
api/services/suggestion_wsjf.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from api.models.suggestion import Suggestion
|
||||
|
||||
|
||||
def cost_of_delay(suggestion: Suggestion) -> float:
|
||||
return suggestion.base_value + (suggestion.relevance_weight * suggestion.relevance)
|
||||
|
||||
|
||||
def compute_wsjf(suggestion: Suggestion) -> float:
|
||||
size = suggestion.job_size if suggestion.job_size > 0 else 3.0
|
||||
return round(cost_of_delay(suggestion) / size, 2)
|
||||
|
||||
|
||||
def suggestion_sort_key(suggestion: Suggestion) -> tuple[float, int, str]:
|
||||
"""Higher WSJF first; tie-break on relevance then title."""
|
||||
return (-compute_wsjf(suggestion), -suggestion.relevance, suggestion.title.lower())
|
||||
@@ -45,6 +45,11 @@ WRITE_ROUTE_RULES: tuple[WriteRouteRule, ...] = (
|
||||
WriteRouteRule("POST", r"/decisions/[^/]+/resolve", "replace", "resolve decision"),
|
||||
WriteRouteRule("PATCH", r"/workplans/[^/]+", "replace", "update workplan"),
|
||||
WriteRouteRule("PATCH", r"/workstreams/[^/]+", "replace", "update legacy workstream alias"),
|
||||
WriteRouteRule("POST", r"/suggestions", "append", "create suggestion"),
|
||||
WriteRouteRule("POST", r"/suggestions/[^/]+/vet", "replace", "vet suggestion"),
|
||||
WriteRouteRule("POST", r"/suggestions/[^/]+/decline", "replace", "decline suggestion"),
|
||||
WriteRouteRule("POST", r"/suggestions/[^/]+/promote", "replace", "promote suggestion to task"),
|
||||
WriteRouteRule("POST", r"/suggestions/[^/]+/bump-relevance", "append", "bump suggestion relevance"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user