kin: KIN-135-backend_dev

This commit is contained in:
Gros Frumos 2026-03-20 21:56:46 +02:00
parent 4c01e0e4ee
commit 24be66d16c
9 changed files with 436 additions and 6 deletions

View file

@ -70,6 +70,17 @@ def build_context(
ctx["available_specialists"] = []
ctx["routes"] = {}
ctx["departments"] = {}
# KIN-135: return history for escalation routing
try:
return_count = (task or {}).get("return_count") or 0
ctx["return_count"] = return_count
if return_count > 0:
ctx["return_history"] = models.get_task_returns(conn, task_id, limit=5)
else:
ctx["return_history"] = []
except Exception:
ctx["return_count"] = 0
ctx["return_history"] = []
elif role == "architect":
ctx["modules"] = models.get_modules(conn, project_id)
@ -151,6 +162,17 @@ def build_context(
except Exception:
pass
elif role == "return_analyst":
# KIN-135: return analyst needs full return history and decisions
ctx["decisions"] = models.get_decisions(conn, project_id)
try:
return_count = (task or {}).get("return_count") or 0
ctx["return_count"] = return_count
ctx["return_history"] = models.get_task_returns(conn, task_id, limit=20)
except Exception:
ctx["return_count"] = 0
ctx["return_history"] = []
else:
# Unknown role — give decisions as fallback
ctx["decisions"] = models.get_decisions(conn, project_id, limit=20)
@ -297,6 +319,20 @@ def format_prompt(context: dict, role: str, prompt_template: str | None = None)
sections.append(f"- {t['id']}: {t['title']} [{t['status']}]")
sections.append("")
# Return history (PM) — KIN-135
return_count = context.get("return_count", 0)
return_history = context.get("return_history")
if return_count and return_count > 0:
sections.append(f"## Return History (return_count={return_count}):")
if return_history:
for r in return_history:
reason_text = f"{r['reason_text']}" if r.get("reason_text") else ""
sections.append(
f"- #{r['return_number']} [{r['reason_category']}]{reason_text} "
f"(returned_by={r.get('returned_by', 'system')}, at={r.get('returned_at', '')})"
)
sections.append("")
# Available specialists (PM)
specialists = context.get("available_specialists")
if specialists:

View file

@ -72,6 +72,7 @@ CREATE TABLE IF NOT EXISTS tasks (
telegram_sent BOOLEAN DEFAULT 0,
acceptance_criteria TEXT,
smoke_test_result JSON DEFAULT NULL,
return_count INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@ -151,6 +152,7 @@ CREATE TABLE IF NOT EXISTS pipelines (
parent_pipeline_id INTEGER REFERENCES pipelines(id),
department TEXT,
pid INTEGER,
pipeline_type TEXT DEFAULT 'standard',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME
);
@ -326,6 +328,20 @@ CREATE TABLE IF NOT EXISTS pipeline_log (
);
CREATE INDEX IF NOT EXISTS idx_pipeline_log_pipeline_id ON pipeline_log(pipeline_id, id);
-- История возвратов задачи к PM (KIN-135)
CREATE TABLE IF NOT EXISTS task_returns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
return_number INTEGER NOT NULL,
reason_category TEXT NOT NULL,
reason_text TEXT,
returned_by TEXT DEFAULT 'system',
pipeline_id INTEGER REFERENCES pipelines(id),
returned_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_task_returns_task_id ON task_returns(task_id);
"""
@ -800,6 +816,39 @@ def _migrate(conn: sqlite3.Connection):
conn.execute("ALTER TABLE tasks ADD COLUMN smoke_test_result JSON DEFAULT NULL")
conn.commit()
# KIN-135: Add return_count to tasks — counts full returns to PM
task_cols_final3 = {r[1] for r in conn.execute("PRAGMA table_info(tasks)").fetchall()}
if "return_count" not in task_cols_final3:
conn.execute("ALTER TABLE tasks ADD COLUMN return_count INTEGER DEFAULT 0")
conn.commit()
# KIN-135: Add pipeline_type to pipelines — distinguishes escalation pipelines
if "pipelines" in existing_tables:
pipeline_cols2 = {r[1] for r in conn.execute("PRAGMA table_info(pipelines)").fetchall()}
if "pipeline_type" not in pipeline_cols2:
conn.execute("ALTER TABLE pipelines ADD COLUMN pipeline_type TEXT DEFAULT 'standard'")
conn.commit()
# KIN-135: Create task_returns table — return history per task
existing_tables2 = {r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
if "task_returns" not in existing_tables2:
conn.executescript("""
CREATE TABLE IF NOT EXISTS task_returns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
return_number INTEGER NOT NULL,
reason_category TEXT NOT NULL,
reason_text TEXT,
returned_by TEXT DEFAULT 'system',
pipeline_id INTEGER REFERENCES pipelines(id),
returned_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_task_returns_task_id ON task_returns(task_id);
""")
conn.commit()
def _seed_default_hooks(conn: sqlite3.Connection):
"""Seed default hooks for the kin project (idempotent).

View file

@ -23,6 +23,16 @@ TASK_CATEGORIES = [
"ARCH", "TEST", "PERF", "DOCS", "FIX", "OBS",
]
# Valid categories for task returns (KIN-135)
RETURN_CATEGORIES = [
"requirements_unclear",
"scope_too_large",
"technical_blocker",
"missing_context",
"recurring_quality_fail",
"conflicting_requirements",
]
def validate_completion_mode(value: str) -> str:
"""Validate completion mode from LLM output. Falls back to 'review' if invalid."""
@ -1301,3 +1311,97 @@ def get_chat_messages(
query += " ORDER BY created_at ASC, id ASC LIMIT ?"
params.append(limit)
return _rows_to_list(conn.execute(query, params).fetchall())
# ---------------------------------------------------------------------------
# Task returns — escalation tracking (KIN-135)
# ---------------------------------------------------------------------------
def record_task_return(
conn: sqlite3.Connection,
task_id: str,
reason_category: str,
reason_text: str | None = None,
returned_by: str = "system",
pipeline_id: int | None = None,
) -> dict:
"""Record a task return to PM and increment return_count.
reason_category must be one of RETURN_CATEGORIES; defaults to 'missing_context'
if an invalid value is supplied (fail-open).
Returns the inserted task_returns row as dict.
"""
if reason_category not in RETURN_CATEGORIES:
reason_category = "missing_context"
# Determine the next return_number for this task
row = conn.execute(
"SELECT COALESCE(MAX(return_number), 0) FROM task_returns WHERE task_id = ?",
(task_id,),
).fetchone()
next_number = (row[0] if row else 0) + 1
conn.execute(
"""INSERT INTO task_returns
(task_id, return_number, reason_category, reason_text, returned_by, pipeline_id)
VALUES (?, ?, ?, ?, ?, ?)""",
(task_id, next_number, reason_category, reason_text, returned_by, pipeline_id),
)
conn.execute(
"UPDATE tasks SET return_count = return_count + 1 WHERE id = ?",
(task_id,),
)
conn.commit()
inserted = conn.execute(
"SELECT * FROM task_returns WHERE task_id = ? ORDER BY id DESC LIMIT 1",
(task_id,),
).fetchone()
return dict(inserted) if inserted else {}
def get_task_returns(
conn: sqlite3.Connection,
task_id: str,
limit: int = 20,
) -> list[dict]:
"""Return task return history ordered by return_number ASC."""
rows = conn.execute(
"SELECT * FROM task_returns WHERE task_id = ? ORDER BY return_number ASC LIMIT ?",
(task_id, limit),
).fetchall()
return [dict(r) for r in rows]
def check_return_pattern(
conn: sqlite3.Connection,
task_id: str,
) -> dict:
"""Analyse return history for a recurring pattern.
Returns:
{
"pattern_detected": bool, # True if dominant_category appears >= 2 times
"dominant_category": str | None,
"occurrences": int,
}
"""
from collections import Counter
rows = conn.execute(
"SELECT reason_category FROM task_returns WHERE task_id = ?",
(task_id,),
).fetchall()
if not rows:
return {"pattern_detected": False, "dominant_category": None, "occurrences": 0}
counts = Counter(r[0] for r in rows)
dominant_category, occurrences = counts.most_common(1)[0]
pattern_detected = occurrences >= 2
return {
"pattern_detected": pattern_detected,
"dominant_category": dominant_category,
"occurrences": occurrences,
}