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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user