249 lines
7.2 KiB
Python
249 lines
7.2 KiB
Python
|
|
"""
|
||
|
|
Kin Web API — FastAPI backend reading ~/.kin/kin.db via core.models.
|
||
|
|
Run: uvicorn web.api:app --reload --port 8420
|
||
|
|
"""
|
||
|
|
|
||
|
|
import sys
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
# Ensure project root on sys.path
|
||
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||
|
|
|
||
|
|
from fastapi import FastAPI, HTTPException, Query
|
||
|
|
from fastapi.middleware.cors import CORSMiddleware
|
||
|
|
from pydantic import BaseModel
|
||
|
|
|
||
|
|
from core.db import init_db
|
||
|
|
from core import models
|
||
|
|
from agents.bootstrap import (
|
||
|
|
detect_tech_stack, detect_modules, extract_decisions_from_claude_md,
|
||
|
|
find_vault_root, scan_obsidian, save_to_db,
|
||
|
|
)
|
||
|
|
|
||
|
|
DB_PATH = Path.home() / ".kin" / "kin.db"
|
||
|
|
|
||
|
|
app = FastAPI(title="Kin API", version="0.1.0")
|
||
|
|
|
||
|
|
app.add_middleware(
|
||
|
|
CORSMiddleware,
|
||
|
|
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
|
||
|
|
allow_methods=["*"],
|
||
|
|
allow_headers=["*"],
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def get_conn():
|
||
|
|
return init_db(DB_PATH)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Projects
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
@app.get("/api/projects")
|
||
|
|
def list_projects(status: str | None = None):
|
||
|
|
conn = get_conn()
|
||
|
|
summary = models.get_project_summary(conn)
|
||
|
|
if status:
|
||
|
|
summary = [s for s in summary if s["status"] == status]
|
||
|
|
conn.close()
|
||
|
|
return summary
|
||
|
|
|
||
|
|
|
||
|
|
@app.get("/api/projects/{project_id}")
|
||
|
|
def get_project(project_id: str):
|
||
|
|
conn = get_conn()
|
||
|
|
p = models.get_project(conn, project_id)
|
||
|
|
if not p:
|
||
|
|
conn.close()
|
||
|
|
raise HTTPException(404, f"Project '{project_id}' not found")
|
||
|
|
tasks = models.list_tasks(conn, project_id=project_id)
|
||
|
|
mods = models.get_modules(conn, project_id)
|
||
|
|
decisions = models.get_decisions(conn, project_id)
|
||
|
|
conn.close()
|
||
|
|
return {**p, "tasks": tasks, "modules": mods, "decisions": decisions}
|
||
|
|
|
||
|
|
|
||
|
|
class ProjectCreate(BaseModel):
|
||
|
|
id: str
|
||
|
|
name: str
|
||
|
|
path: str
|
||
|
|
tech_stack: list[str] | None = None
|
||
|
|
status: str = "active"
|
||
|
|
priority: int = 5
|
||
|
|
|
||
|
|
|
||
|
|
@app.post("/api/projects")
|
||
|
|
def create_project(body: ProjectCreate):
|
||
|
|
conn = get_conn()
|
||
|
|
if models.get_project(conn, body.id):
|
||
|
|
conn.close()
|
||
|
|
raise HTTPException(409, f"Project '{body.id}' already exists")
|
||
|
|
p = models.create_project(
|
||
|
|
conn, body.id, body.name, body.path,
|
||
|
|
tech_stack=body.tech_stack, status=body.status, priority=body.priority,
|
||
|
|
)
|
||
|
|
conn.close()
|
||
|
|
return p
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Tasks
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
@app.get("/api/tasks/{task_id}")
|
||
|
|
def get_task(task_id: str):
|
||
|
|
conn = get_conn()
|
||
|
|
t = models.get_task(conn, task_id)
|
||
|
|
conn.close()
|
||
|
|
if not t:
|
||
|
|
raise HTTPException(404, f"Task '{task_id}' not found")
|
||
|
|
return t
|
||
|
|
|
||
|
|
|
||
|
|
class TaskCreate(BaseModel):
|
||
|
|
project_id: str
|
||
|
|
title: str
|
||
|
|
priority: int = 5
|
||
|
|
route_type: str | None = None
|
||
|
|
|
||
|
|
|
||
|
|
@app.post("/api/tasks")
|
||
|
|
def create_task(body: TaskCreate):
|
||
|
|
conn = get_conn()
|
||
|
|
p = models.get_project(conn, body.project_id)
|
||
|
|
if not p:
|
||
|
|
conn.close()
|
||
|
|
raise HTTPException(404, f"Project '{body.project_id}' not found")
|
||
|
|
# Auto-generate task ID
|
||
|
|
existing = models.list_tasks(conn, project_id=body.project_id)
|
||
|
|
prefix = body.project_id.upper()
|
||
|
|
max_num = 0
|
||
|
|
for t in existing:
|
||
|
|
if t["id"].startswith(prefix + "-"):
|
||
|
|
try:
|
||
|
|
num = int(t["id"].split("-", 1)[1])
|
||
|
|
max_num = max(max_num, num)
|
||
|
|
except ValueError:
|
||
|
|
pass
|
||
|
|
task_id = f"{prefix}-{max_num + 1:03d}"
|
||
|
|
brief = {"route_type": body.route_type} if body.route_type else None
|
||
|
|
t = models.create_task(conn, task_id, body.project_id, body.title,
|
||
|
|
priority=body.priority, brief=brief)
|
||
|
|
conn.close()
|
||
|
|
return t
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Decisions
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
@app.get("/api/decisions")
|
||
|
|
def list_decisions(
|
||
|
|
project: str = Query(...),
|
||
|
|
category: str | None = None,
|
||
|
|
tag: list[str] | None = Query(None),
|
||
|
|
type: list[str] | None = Query(None),
|
||
|
|
):
|
||
|
|
conn = get_conn()
|
||
|
|
decisions = models.get_decisions(
|
||
|
|
conn, project, category=category, tags=tag, types=type,
|
||
|
|
)
|
||
|
|
conn.close()
|
||
|
|
return decisions
|
||
|
|
|
||
|
|
|
||
|
|
class DecisionCreate(BaseModel):
|
||
|
|
project_id: str
|
||
|
|
type: str
|
||
|
|
title: str
|
||
|
|
description: str
|
||
|
|
category: str | None = None
|
||
|
|
tags: list[str] | None = None
|
||
|
|
task_id: str | None = None
|
||
|
|
|
||
|
|
|
||
|
|
@app.post("/api/decisions")
|
||
|
|
def create_decision(body: DecisionCreate):
|
||
|
|
conn = get_conn()
|
||
|
|
p = models.get_project(conn, body.project_id)
|
||
|
|
if not p:
|
||
|
|
conn.close()
|
||
|
|
raise HTTPException(404, f"Project '{body.project_id}' not found")
|
||
|
|
d = models.add_decision(
|
||
|
|
conn, body.project_id, body.type, body.title, body.description,
|
||
|
|
category=body.category, tags=body.tags, task_id=body.task_id,
|
||
|
|
)
|
||
|
|
conn.close()
|
||
|
|
return d
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Cost
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
@app.get("/api/cost")
|
||
|
|
def cost_summary(days: int = 7):
|
||
|
|
conn = get_conn()
|
||
|
|
costs = models.get_cost_summary(conn, days=days)
|
||
|
|
conn.close()
|
||
|
|
return costs
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Support
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
@app.get("/api/support/tickets")
|
||
|
|
def list_tickets(project: str | None = None, status: str | None = None):
|
||
|
|
conn = get_conn()
|
||
|
|
tickets = models.list_tickets(conn, project_id=project, status=status)
|
||
|
|
conn.close()
|
||
|
|
return tickets
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Bootstrap
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class BootstrapRequest(BaseModel):
|
||
|
|
path: str
|
||
|
|
id: str
|
||
|
|
name: str
|
||
|
|
vault_path: str | None = None
|
||
|
|
|
||
|
|
|
||
|
|
@app.post("/api/bootstrap")
|
||
|
|
def bootstrap(body: BootstrapRequest):
|
||
|
|
project_path = Path(body.path).expanduser().resolve()
|
||
|
|
if not project_path.is_dir():
|
||
|
|
raise HTTPException(400, f"Path '{body.path}' is not a directory")
|
||
|
|
|
||
|
|
conn = get_conn()
|
||
|
|
if models.get_project(conn, body.id):
|
||
|
|
conn.close()
|
||
|
|
raise HTTPException(409, f"Project '{body.id}' already exists")
|
||
|
|
|
||
|
|
tech_stack = detect_tech_stack(project_path)
|
||
|
|
modules = detect_modules(project_path)
|
||
|
|
decisions = extract_decisions_from_claude_md(project_path, body.id, body.name)
|
||
|
|
|
||
|
|
obsidian = None
|
||
|
|
vault_root = find_vault_root(Path(body.vault_path) if body.vault_path else None)
|
||
|
|
if vault_root:
|
||
|
|
dir_name = project_path.name
|
||
|
|
obs = scan_obsidian(vault_root, body.id, body.name, dir_name)
|
||
|
|
if obs["tasks"] or obs["decisions"]:
|
||
|
|
obsidian = obs
|
||
|
|
|
||
|
|
save_to_db(conn, body.id, body.name, str(project_path),
|
||
|
|
tech_stack, modules, decisions, obsidian)
|
||
|
|
p = models.get_project(conn, body.id)
|
||
|
|
conn.close()
|
||
|
|
return {
|
||
|
|
"project": p,
|
||
|
|
"modules_count": len(modules),
|
||
|
|
"decisions_count": len(decisions) + len((obsidian or {}).get("decisions", [])),
|
||
|
|
"tasks_count": len((obsidian or {}).get("tasks", [])),
|
||
|
|
}
|