| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
use std::sync::Arc; |
| 14 |
|
| 15 |
use async_trait::async_trait; |
| 16 |
use goingson_core::models::DbValue; |
| 17 |
use goingson_core::repository::ProjectRepository; |
| 18 |
use goingson_core::{NewProject, ProjectStatus, ProjectType, UpdateProject}; |
| 19 |
use kberg::{Error, Result, Tool, ToolCallResult, ToolKind}; |
| 20 |
use serde_json::{Value, json}; |
| 21 |
|
| 22 |
use crate::caps; |
| 23 |
use crate::context::Ctx; |
| 24 |
use crate::convert::{parse_enum, req_str}; |
| 25 |
|
| 26 |
|
| 27 |
|
| 28 |
const PROJECT_TYPES: &[&str] = &[ |
| 29 |
"Job", |
| 30 |
"SideProject", |
| 31 |
"Company", |
| 32 |
"Essay", |
| 33 |
"Article", |
| 34 |
"Painting", |
| 35 |
"Other", |
| 36 |
]; |
| 37 |
const PROJECT_STATUSES: &[&str] = &["Active", "OnHold", "Completed", "Archived"]; |
| 38 |
|
| 39 |
fn fail(tool: &str, e: impl std::fmt::Display) -> Error { |
| 40 |
Error::ToolFailed { |
| 41 |
tool: tool.to_string(), |
| 42 |
message: e.to_string(), |
| 43 |
} |
| 44 |
} |
| 45 |
|
| 46 |
pub struct ListProjects(pub Arc<Ctx>); |
| 47 |
|
| 48 |
#[async_trait] |
| 49 |
impl Tool for ListProjects { |
| 50 |
fn name(&self) -> &str { |
| 51 |
"list_projects" |
| 52 |
} |
| 53 |
fn description(&self) -> &str { |
| 54 |
"List all projects (id, name, type, status). Use to resolve a project name to its id before creating tasks." |
| 55 |
} |
| 56 |
fn kind(&self) -> ToolKind { |
| 57 |
ToolKind::Read |
| 58 |
} |
| 59 |
fn small_model_safe(&self) -> bool { |
| 60 |
true |
| 61 |
} |
| 62 |
fn input_schema(&self) -> Value { |
| 63 |
json!({ "type": "object", "properties": {} }) |
| 64 |
} |
| 65 |
async fn call(&self, _args: Value) -> Result<ToolCallResult> { |
| 66 |
let projects = self |
| 67 |
.0 |
| 68 |
.projects() |
| 69 |
.list_all(self.0.user_id) |
| 70 |
.await |
| 71 |
.map_err(|e| fail(self.name(), e))?; |
| 72 |
let rows: Vec<Value> = projects |
| 73 |
.iter() |
| 74 |
.map(|p| { |
| 75 |
json!({ |
| 76 |
"id": p.id.to_string(), |
| 77 |
"name": p.name, |
| 78 |
"type": p.project_type.db_value(), |
| 79 |
"status": p.status.db_value(), |
| 80 |
}) |
| 81 |
}) |
| 82 |
.collect(); |
| 83 |
Ok(ToolCallResult::text( |
| 84 |
serde_json::to_string(&json!({ "projects": rows })).unwrap(), |
| 85 |
)) |
| 86 |
} |
| 87 |
} |
| 88 |
|
| 89 |
pub struct CreateProject(pub Arc<Ctx>); |
| 90 |
|
| 91 |
#[async_trait] |
| 92 |
impl Tool for CreateProject { |
| 93 |
fn name(&self) -> &str { |
| 94 |
"create_project" |
| 95 |
} |
| 96 |
fn description(&self) -> &str { |
| 97 |
"Create a project. Idempotent on name: if a project with the same name exists, its id is returned instead of creating a duplicate. `type` is one of Job, SideProject, Company, Essay, Article, Painting, Other (defaults to SideProject)." |
| 98 |
} |
| 99 |
fn kind(&self) -> ToolKind { |
| 100 |
ToolKind::Write(caps::project_create()) |
| 101 |
} |
| 102 |
fn input_schema(&self) -> Value { |
| 103 |
json!({ |
| 104 |
"type": "object", |
| 105 |
"properties": { |
| 106 |
"name": { "type": "string" }, |
| 107 |
"type": { "type": "string", "description": "Job | SideProject | Company | Essay | Article | Painting | Other" }, |
| 108 |
"description": { "type": "string" } |
| 109 |
}, |
| 110 |
"required": ["name"] |
| 111 |
}) |
| 112 |
} |
| 113 |
async fn call(&self, args: Value) -> Result<ToolCallResult> { |
| 114 |
let name = req_str(self.name(), &args, "name")?; |
| 115 |
let repo = self.0.projects(); |
| 116 |
|
| 117 |
|
| 118 |
if let Some(existing) = repo |
| 119 |
.find_by_name(self.0.user_id, name) |
| 120 |
.await |
| 121 |
.map_err(|e| fail(self.name(), e))? |
| 122 |
{ |
| 123 |
return Ok(ToolCallResult::text( |
| 124 |
serde_json::to_string(&json!({ |
| 125 |
"id": existing.id.to_string(), |
| 126 |
"created": false, |
| 127 |
})) |
| 128 |
.unwrap(), |
| 129 |
)); |
| 130 |
} |
| 131 |
|
| 132 |
let project_type = match args.get("type").and_then(Value::as_str) { |
| 133 |
Some(t) => parse_enum(self.name(), "type", t, PROJECT_TYPES)?, |
| 134 |
None => ProjectType::SideProject, |
| 135 |
}; |
| 136 |
let description = args |
| 137 |
.get("description") |
| 138 |
.and_then(Value::as_str) |
| 139 |
.unwrap_or_default() |
| 140 |
.to_string(); |
| 141 |
|
| 142 |
let created = repo |
| 143 |
.create( |
| 144 |
self.0.user_id, |
| 145 |
NewProject { |
| 146 |
name: name.to_string(), |
| 147 |
description, |
| 148 |
project_type, |
| 149 |
status: ProjectStatus::default(), |
| 150 |
}, |
| 151 |
) |
| 152 |
.await |
| 153 |
.map_err(|e| fail(self.name(), e))?; |
| 154 |
|
| 155 |
Ok(ToolCallResult::text( |
| 156 |
serde_json::to_string(&json!({ |
| 157 |
"id": created.id.to_string(), |
| 158 |
"created": true, |
| 159 |
})) |
| 160 |
.unwrap(), |
| 161 |
)) |
| 162 |
} |
| 163 |
} |
| 164 |
|
| 165 |
|
| 166 |
|
| 167 |
pub struct UpdateProjectTool(pub Arc<Ctx>); |
| 168 |
|
| 169 |
#[async_trait] |
| 170 |
impl Tool for UpdateProjectTool { |
| 171 |
fn name(&self) -> &str { |
| 172 |
"update_project" |
| 173 |
} |
| 174 |
fn description(&self) -> &str { |
| 175 |
"Update an existing project, resolved by `project` (its name). Only the fields you pass change. `status` is Active|OnHold|Completed|Archived. Archived is the non-destructive way to retire a project. `type` is Job|SideProject|Company|Essay|Article|Painting|Other. Renaming is not offered: the name is how create_task and bulk_import_tasks resolve a project, so a rename would strand callers." |
| 176 |
} |
| 177 |
fn kind(&self) -> ToolKind { |
| 178 |
ToolKind::Write(caps::project_update()) |
| 179 |
} |
| 180 |
fn input_schema(&self) -> Value { |
| 181 |
json!({ |
| 182 |
"type": "object", |
| 183 |
"properties": { |
| 184 |
"project": { "type": "string", "description": "The project's current name." }, |
| 185 |
"status": { "type": "string", "description": "Active | OnHold | Completed | Archived" }, |
| 186 |
"type": { "type": "string", "description": "Job | SideProject | Company | Essay | Article | Painting | Other" }, |
| 187 |
"description": { "type": "string" } |
| 188 |
}, |
| 189 |
"required": ["project"] |
| 190 |
}) |
| 191 |
} |
| 192 |
async fn call(&self, args: Value) -> Result<ToolCallResult> { |
| 193 |
let name = req_str(self.name(), &args, "project")?; |
| 194 |
let repo = self.0.projects(); |
| 195 |
|
| 196 |
|
| 197 |
let current = repo |
| 198 |
.find_by_name(self.0.user_id, name) |
| 199 |
.await |
| 200 |
.map_err(|e| fail(self.name(), e))? |
| 201 |
.ok_or_else(|| Error::ToolFailed { |
| 202 |
tool: self.name().to_string(), |
| 203 |
message: format!("no project named `{name}`"), |
| 204 |
})?; |
| 205 |
|
| 206 |
|
| 207 |
|
| 208 |
let mut patch = UpdateProject { |
| 209 |
name: current.name.clone(), |
| 210 |
description: current.description.clone(), |
| 211 |
project_type: current.project_type.clone(), |
| 212 |
status: current.status.clone(), |
| 213 |
}; |
| 214 |
if let Some(status) = args.get("status").and_then(Value::as_str) { |
| 215 |
patch.status = parse_enum(self.name(), "status", status, PROJECT_STATUSES)?; |
| 216 |
} |
| 217 |
if let Some(ty) = args.get("type").and_then(Value::as_str) { |
| 218 |
patch.project_type = parse_enum(self.name(), "type", ty, PROJECT_TYPES)?; |
| 219 |
} |
| 220 |
if let Some(desc) = args.get("description").and_then(Value::as_str) { |
| 221 |
patch.description = desc.to_string(); |
| 222 |
} |
| 223 |
|
| 224 |
let updated = repo |
| 225 |
.update(current.id, self.0.user_id, patch) |
| 226 |
.await |
| 227 |
.map_err(|e| fail(self.name(), e))? |
| 228 |
.ok_or_else(|| Error::ToolFailed { |
| 229 |
tool: self.name().to_string(), |
| 230 |
message: format!("project `{name}` vanished during update"), |
| 231 |
})?; |
| 232 |
|
| 233 |
Ok(ToolCallResult::text( |
| 234 |
serde_json::to_string(&json!({ |
| 235 |
"id": updated.id.to_string(), |
| 236 |
"name": updated.name, |
| 237 |
"type": updated.project_type.db_value(), |
| 238 |
"status": updated.status.db_value(), |
| 239 |
"description": updated.description, |
| 240 |
})) |
| 241 |
.unwrap(), |
| 242 |
)) |
| 243 |
} |
| 244 |
} |
| 245 |
|