kin: KIN-016 Агенты должны уметь говорить 'не могу'. Если агент не может выполнить задачу (нет доступа, не понимает, выходит за компетенцию) — он должен вернуть status: blocked с причиной, а не пытаться угадывать. PM при получении blocked от агента — эскалирует к человеку через GUI (уведомление) и Telegram (когда будет).
This commit is contained in:
parent
a605e9d110
commit
d9172fc17c
35 changed files with 2375 additions and 23 deletions
208
core/phases.py
Normal file
208
core/phases.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
"""
|
||||
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,
|
||||
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,
|
||||
)
|
||||
return {"phase": updated, "new_task": new_task}
|
||||
Loading…
Add table
Add a link
Reference in a new issue