kin/core/phases.py

210 lines
6.6 KiB
Python

"""
Kin — Research Phase Pipeline (KIN-059).
Sequential workflow: Director describes a new project, picks researcher roles,
each phase produces a task for review. After approve → next phase activates.
Architect always runs last (auto-added when any researcher is selected).
"""
import sqlite3
from core import models
# Canonical order of research roles (architect always last)
RESEARCH_ROLES = [
"business_analyst",
"market_researcher",
"legal_researcher",
"tech_researcher",
"ux_designer",
"marketer",
"architect",
]
# Human-readable labels
ROLE_LABELS = {
"business_analyst": "Business Analyst",
"market_researcher": "Market Researcher",
"legal_researcher": "Legal Researcher",
"tech_researcher": "Tech Researcher",
"ux_designer": "UX Designer",
"marketer": "Marketer",
"architect": "Architect",
}
def validate_roles(roles: list[str]) -> list[str]:
"""Filter unknown roles, remove duplicates, strip 'architect' (auto-added later)."""
seen: set[str] = set()
result = []
for r in roles:
r = r.strip().lower()
if r == "architect":
continue
if r in RESEARCH_ROLES and r not in seen:
seen.add(r)
result.append(r)
return result
def build_phase_order(selected_roles: list[str]) -> list[str]:
"""Return roles in canonical RESEARCH_ROLES order, append architect if any selected."""
ordered = [r for r in RESEARCH_ROLES if r in selected_roles and r != "architect"]
if ordered:
ordered.append("architect")
return ordered
def create_project_with_phases(
conn: sqlite3.Connection,
id: str,
name: str,
path: str | None = None,
*,
description: str,
selected_roles: list[str],
tech_stack: list | None = None,
priority: int = 5,
language: str = "ru",
) -> dict:
"""Create project + sequential research phases.
Returns {project, phases}.
"""
clean_roles = validate_roles(selected_roles)
ordered_roles = build_phase_order(clean_roles)
if not ordered_roles:
raise ValueError("At least one research role must be selected")
project = models.create_project(
conn, id, name, path,
tech_stack=tech_stack, priority=priority, language=language,
description=description,
)
phases = []
for idx, role in enumerate(ordered_roles):
phase = models.create_phase(conn, id, role, idx)
phases.append(phase)
# Activate the first phase immediately
if phases:
phases[0] = activate_phase(conn, phases[0]["id"])
return {"project": project, "phases": phases}
def activate_phase(conn: sqlite3.Connection, phase_id: int) -> dict:
"""Create a task for the phase and set it to active.
Task brief includes project description + phase context.
"""
phase = models.get_phase(conn, phase_id)
if not phase:
raise ValueError(f"Phase {phase_id} not found")
project = models.get_project(conn, phase["project_id"])
if not project:
raise ValueError(f"Project {phase['project_id']} not found")
task_id = models.next_task_id(conn, phase["project_id"], category=None)
brief = {
"text": project.get("description") or project["name"],
"phase": phase["role"],
"phase_order": phase["phase_order"],
"workflow": "research",
}
task = models.create_task(
conn, task_id, phase["project_id"],
title=f"[Research] {ROLE_LABELS.get(phase['role'], phase['role'])}",
assigned_role=phase["role"],
brief=brief,
status="pending",
category=None,
)
updated = models.update_phase(conn, phase_id, task_id=task["id"], status="active")
return updated
def approve_phase(conn: sqlite3.Connection, phase_id: int) -> dict:
"""Approve a phase, activate the next one (or finish workflow).
Returns {phase, next_phase|None}.
"""
phase = models.get_phase(conn, phase_id)
if not phase:
raise ValueError(f"Phase {phase_id} not found")
if phase["status"] != "active":
raise ValueError(f"Phase {phase_id} is not active (current: {phase['status']})")
updated = models.update_phase(conn, phase_id, status="approved")
# Find next pending phase
all_phases = models.list_phases(conn, phase["project_id"])
next_phase = None
for p in all_phases:
if p["phase_order"] > phase["phase_order"] and p["status"] == "pending":
next_phase = p
break
if next_phase:
activated = activate_phase(conn, next_phase["id"])
return {"phase": updated, "next_phase": activated}
return {"phase": updated, "next_phase": None}
def reject_phase(conn: sqlite3.Connection, phase_id: int, reason: str) -> dict:
"""Reject a phase (director rejects the research output entirely)."""
phase = models.get_phase(conn, phase_id)
if not phase:
raise ValueError(f"Phase {phase_id} not found")
if phase["status"] != "active":
raise ValueError(f"Phase {phase_id} is not active (current: {phase['status']})")
return models.update_phase(conn, phase_id, status="rejected")
def revise_phase(conn: sqlite3.Connection, phase_id: int, comment: str) -> dict:
"""Request revision: create a new task for the same role with the comment.
Returns {phase, new_task}.
"""
phase = models.get_phase(conn, phase_id)
if not phase:
raise ValueError(f"Phase {phase_id} not found")
if phase["status"] not in ("active", "revising"):
raise ValueError(
f"Phase {phase_id} cannot be revised (current: {phase['status']})"
)
project = models.get_project(conn, phase["project_id"])
if not project:
raise ValueError(f"Project {phase['project_id']} not found")
new_task_id = models.next_task_id(conn, phase["project_id"], category=None)
brief = {
"text": project.get("description") or project["name"],
"phase": phase["role"],
"phase_order": phase["phase_order"],
"workflow": "research",
"revise_comment": comment,
"revise_count": (phase.get("revise_count") or 0) + 1,
}
new_task = models.create_task(
conn, new_task_id, phase["project_id"],
title=f"[Research Revise] {ROLE_LABELS.get(phase['role'], phase['role'])}",
assigned_role=phase["role"],
brief=brief,
status="pending",
category=None,
)
new_revise_count = (phase.get("revise_count") or 0) + 1
updated = models.update_phase(
conn, phase_id,
status="revising",
task_id=new_task["id"],
revise_count=new_revise_count,
revise_comment=comment,
)
return {"phase": updated, "new_task": new_task}