AdvisorActions.vala 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
  1. using Inversion;
  2. using Invercargill;
  3. using Invercargill.Expressions;
  4. using Spry;
  5. using Spry.Authentication;
  6. using Statum;
  7. using ClankerSaddle.Data;
  8. using ClankerSaddle.Services;
  9. namespace ClankerSaddle {
  10. /** Sealed chat id carried by the advisor chat actions. */
  11. public class AdvisorChatActionPrivate : Object {
  12. public int64 chat_id { get; set; }
  13. }
  14. /**
  15. * Sealed suggestion + chat ids carried by the suggestion decision
  16. * action (goal 27 task 3 owns its handler).
  17. */
  18. public class AdvisorSuggestionActionPrivate : Object {
  19. public int64 suggestion_id { get; set; }
  20. public int64 chat_id { get; set; }
  21. }
  22. /**
  23. * Starts a new advisor chat (goal 27) from the dashboard's "Ask
  24. * the advisor" composer: creates the chat titled from the first
  25. * line of the message (clamped ~80 chars), records the message as
  26. * the opening user row and kicks the background
  27. * {@link AdvisorService} turn. The POST returns before the turn
  28. * finishes — the chat page's pulse-driven refresh streams the
  29. * appends in — and the browser navigates to /chats/{id}.
  30. */
  31. public class StartAdvisorChatAction : StatumAction {
  32. private ChatService chat_service = inject<ChatService>();
  33. private AdvisorService advisor_service = inject<AdvisorService>();
  34. private UserService users = inject<UserService>();
  35. public override async DirectiveBuilder handle() throws GLib.Error {
  36. var guard = yield SpryAuth.require_login(directives(), request, users);
  37. if (guard != null) {
  38. return (!)guard;
  39. }
  40. var me = SpryAuth.current(request);
  41. if (me == null) {
  42. return directives().navigate("/");
  43. }
  44. int64 viewer_id = ((!)me).user_id;
  45. string message = Forms.field_raw(request, "message");
  46. if (message.strip().length == 0) {
  47. return directives().notify("error",
  48. "Type a question for the advisor first").navigate("/");
  49. }
  50. var chat = yield chat_service.create_chat(viewer_id,
  51. chat_title(message));
  52. yield chat_service.record_user_message(chat.id, message.strip());
  53. // The turn runs in the service's own scope; this request
  54. // finishes (and navigates) immediately.
  55. advisor_service.run_turn.begin(chat.id, viewer_id);
  56. return directives().navigate(@"/chats/$(chat.id)");
  57. }
  58. /**
  59. * The chat's title: the message's first non-empty line,
  60. * clamped to ~80 characters on a word boundary.
  61. */
  62. private static string chat_title(string message) {
  63. string first = "";
  64. foreach (string line in message.strip().split("\n")) {
  65. if (line.strip().length > 0) {
  66. first = line.strip();
  67. break;
  68. }
  69. }
  70. if (first.length <= 80) {
  71. return first;
  72. }
  73. string cut = first.substring(0, 80);
  74. int last_space = cut.last_index_of(" ");
  75. if (last_space > 40) {
  76. cut = cut.substring(0, last_space);
  77. }
  78. return cut.strip() + "…";
  79. }
  80. }
  81. /**
  82. * Appends the composer's message to a chat and kicks the next
  83. * background advisor turn, answering with the rebuilt `chat` slot
  84. * (the user's own row appears at once; the turn's appends stream
  85. * in through the pulse refresh). The chat id is sealed.
  86. */
  87. public class SendAdvisorMessageAction : TypedStatumAction<AdvisorChatActionPrivate> {
  88. private ChatService chat_service = inject<ChatService>();
  89. private AdvisorService advisor_service = inject<AdvisorService>();
  90. private UserService users = inject<UserService>();
  91. protected override Type private_type {
  92. get { return typeof(AdvisorChatActionPrivate); }
  93. }
  94. public override async DirectiveBuilder handle() throws GLib.Error {
  95. var guard = yield SpryAuth.require_login(directives(), request, users);
  96. if (guard != null) {
  97. return (!)guard;
  98. }
  99. var ctx = request_private;
  100. if (ctx == null) {
  101. return directives().navigate("/chats");
  102. }
  103. var me = SpryAuth.current(request);
  104. if (me == null) {
  105. return directives().navigate("/chats");
  106. }
  107. int64 viewer_id = ((!)me).user_id;
  108. int64 chat_id = ((!)ctx).chat_id;
  109. var chat = yield chat_service.get_chat(chat_id, viewer_id);
  110. if (chat == null) {
  111. return directives().notify("error", "That chat no longer exists")
  112. .navigate("/chats");
  113. }
  114. string message = Forms.field_raw(request, "message");
  115. // The question card answers through this action too (the
  116. // page's one sealed send DTO serves the composer, the
  117. // option buttons and the "Other" box): an answer resumes
  118. // a PARKED question, a message starts a normal turn.
  119. string answer = Forms.field_raw(request, "answer");
  120. if (answer.strip().length > 0) {
  121. bool accepted = false;
  122. try {
  123. accepted = yield advisor_service.answer_question(chat_id,
  124. viewer_id, answer);
  125. } catch (Error e) {
  126. return directives().notify("error",
  127. @"Could not record the answer: $(e.message)");
  128. }
  129. if (!accepted) {
  130. return directives().notify("error",
  131. "That question has already been answered");
  132. }
  133. var answered = yield chat_service.get_chat(chat_id, viewer_id);
  134. if (answered == null) {
  135. return directives().navigate("/chats");
  136. }
  137. var resumed = yield ChatState.build_from(state_service,
  138. chat_service, advisor_service, action_registry, viewer_id,
  139. answered);
  140. return directives().set(resumed);
  141. }
  142. if (message.strip().length == 0) {
  143. var empty = yield ChatState.build_from(state_service,
  144. chat_service, advisor_service, action_registry, viewer_id,
  145. chat);
  146. return directives().set(empty);
  147. }
  148. yield chat_service.record_user_message(chat_id, message.strip());
  149. advisor_service.run_turn.begin(chat_id, viewer_id);
  150. var updated = yield chat_service.get_chat(chat_id, viewer_id);
  151. var state = yield ChatState.build_from(state_service, chat_service,
  152. advisor_service, action_registry, viewer_id, updated);
  153. return directives().set(state);
  154. }
  155. }
  156. /**
  157. * Records the human's answer to the chat's pending question (the
  158. * question card's option buttons and "Other" box submit here via
  159. * the JSON transport) and resumes the turn; answers with the
  160. * rebuilt `chat` slot. The chat id is sealed.
  161. */
  162. public class AnswerAdvisorQuestionAction : TypedStatumAction<AdvisorChatActionPrivate> {
  163. private ChatService chat_service = inject<ChatService>();
  164. private AdvisorService advisor_service = inject<AdvisorService>();
  165. private UserService users = inject<UserService>();
  166. protected override Type private_type {
  167. get { return typeof(AdvisorChatActionPrivate); }
  168. }
  169. public override async DirectiveBuilder handle() throws GLib.Error {
  170. var guard = yield SpryAuth.require_login(directives(), request, users);
  171. if (guard != null) {
  172. return (!)guard;
  173. }
  174. var ctx = request_private;
  175. if (ctx == null) {
  176. return directives().navigate("/chats");
  177. }
  178. var me = SpryAuth.current(request);
  179. if (me == null) {
  180. return directives().navigate("/chats");
  181. }
  182. int64 viewer_id = ((!)me).user_id;
  183. int64 chat_id = ((!)ctx).chat_id;
  184. var chat = yield chat_service.get_chat(chat_id, viewer_id);
  185. if (chat == null) {
  186. return directives().notify("error", "That chat no longer exists")
  187. .navigate("/chats");
  188. }
  189. string answer = Forms.field_raw(request, "answer");
  190. // answer_question records the reply and runs the next
  191. // turn in its own scope; the answer arrives as this
  192. // action's reply.
  193. bool accepted = false;
  194. try {
  195. accepted = yield advisor_service.answer_question(chat_id,
  196. viewer_id, answer);
  197. } catch (Error e) {
  198. return directives().notify("error",
  199. @"Could not record the answer: $(e.message)");
  200. }
  201. if (!accepted) {
  202. return directives().notify("error",
  203. "That question has already been answered").navigate(
  204. @"/chats/$(chat_id)");
  205. }
  206. var updated = yield chat_service.get_chat(chat_id, viewer_id);
  207. if (updated == null) {
  208. return directives().navigate("/chats");
  209. }
  210. var state = yield ChatState.build_from(state_service, chat_service,
  211. advisor_service, action_registry, viewer_id, updated);
  212. return directives().set(state);
  213. }
  214. }
  215. /**
  216. * Deletes a chat (its messages and suggestions cascade) and sends
  217. * the browser to /chats. The chat id is sealed; deleting someone
  218. * else's id is a silent no-op (chats are private per user).
  219. */
  220. public class DeleteAdvisorChatAction : TypedStatumAction<AdvisorChatActionPrivate> {
  221. private ChatService chat_service = inject<ChatService>();
  222. private UserService users = inject<UserService>();
  223. protected override Type private_type {
  224. get { return typeof(AdvisorChatActionPrivate); }
  225. }
  226. public override async DirectiveBuilder handle() throws GLib.Error {
  227. var guard = yield SpryAuth.require_login(directives(), request, users);
  228. if (guard != null) {
  229. return (!)guard;
  230. }
  231. var ctx = request_private;
  232. if (ctx == null) {
  233. return directives().navigate("/chats");
  234. }
  235. var me = SpryAuth.current(request);
  236. if (me == null) {
  237. return directives().navigate("/chats");
  238. }
  239. try {
  240. yield chat_service.delete_chat(((!)ctx).chat_id,
  241. ((!)me).user_id);
  242. return directives().notify("success", "Chat deleted")
  243. .navigate("/chats");
  244. } catch (Error e) {
  245. return directives().notify("error",
  246. @"Could not delete the chat: $(e.message)")
  247. .navigate("/chats");
  248. }
  249. }
  250. }
  251. /**
  252. * The chat page's pulse-driven refresh (goal 27): rebuilds the
  253. * `chat` slot from current rows so a running turn's appends —
  254. * tool calls, the assistant reply, a parked question, a
  255. * suggestion — stream in without a reload. The chat id is SEALED
  256. * (author_private, exactly like send/delete): statum.act carries
  257. * GET parameters as a QUERY STRING, which request.form never
  258. * sees, so an id read from a form field would parse 0 on every
  259. * pulse bump and bounce the page to /chats — the redirect loop.
  260. * Navigates only when the chat genuinely no longer exists (or
  261. * never was the viewer's).
  262. */
  263. public class RefreshAdvisorChatAction : TypedStatumAction<AdvisorChatActionPrivate> {
  264. private ChatService chat_service = inject<ChatService>();
  265. private AdvisorService advisor_service = inject<AdvisorService>();
  266. private UserService users = inject<UserService>();
  267. protected override Type private_type {
  268. get { return typeof(AdvisorChatActionPrivate); }
  269. }
  270. public override async DirectiveBuilder handle() throws GLib.Error {
  271. var guard = yield SpryAuth.require_login(directives(), request, users);
  272. if (guard != null) {
  273. return (!)guard;
  274. }
  275. var ctx = request_private;
  276. if (ctx == null) {
  277. return directives().navigate("/chats");
  278. }
  279. var me = SpryAuth.current(request);
  280. if (me == null) {
  281. return directives().navigate("/chats");
  282. }
  283. var chat = yield chat_service.get_chat(((!)ctx).chat_id,
  284. ((!)me).user_id);
  285. if (chat == null) {
  286. // Deleted mid-turn (or never the viewer's): nothing to
  287. // refresh — the page's own navigation takes over.
  288. return directives().navigate("/chats");
  289. }
  290. var state = yield ChatState.build_from(state_service, chat_service,
  291. advisor_service, action_registry, ((!)me).user_id, chat);
  292. return directives().set(state);
  293. }
  294. }
  295. /**
  296. * Decides a pending suggestion (goal 27): 'approve' creates the
  297. * real row — a DRAFT task (creator human, so the creator-only
  298. * enqueue rule holds by construction) or a DRAFT goal (never
  299. * started: goals are unqueued until the human starts them) —
  300. * 'approve_enqueue' additionally queues the created task for a
  301. * saddle, and 'decline' dismisses the row. Every path appends a
  302. * short system message to the chat and kicks a background advisor
  303. * turn so the advisor can acknowledge the outcome; the action
  304. * answers with the refreshed `chat` slot. The suggestion and chat
  305. * ids are sealed; only the chat's owner may decide, and only
  306. * while the row is still pending.
  307. */
  308. public class DecideAdvisorSuggestionAction : TypedStatumAction<AdvisorSuggestionActionPrivate> {
  309. private ChatService chat_service = inject<ChatService>();
  310. private AdvisorService advisor_service = inject<AdvisorService>();
  311. private TaskService task_service = inject<TaskService>();
  312. private GoalService goal_service = inject<GoalService>();
  313. private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
  314. private UserService users = inject<UserService>();
  315. protected override Type private_type {
  316. get { return typeof(AdvisorSuggestionActionPrivate); }
  317. }
  318. public override async DirectiveBuilder handle() throws GLib.Error {
  319. var guard = yield SpryAuth.require_login(directives(), request, users);
  320. if (guard != null) {
  321. return (!)guard;
  322. }
  323. var ctx = request_private;
  324. var me = SpryAuth.current(request);
  325. if (ctx == null || me == null) {
  326. return directives().navigate("/chats");
  327. }
  328. int64 viewer_id = ((!)me).user_id;
  329. int64 suggestion_id = ((!)ctx).suggestion_id;
  330. int64 chat_id = ((!)ctx).chat_id;
  331. string decision = Forms.field(request, "decision");
  332. if (decision != "approve" && decision != "approve_enqueue"
  333. && decision != "decline") {
  334. return directives().notify("error",
  335. "Unknown suggestion decision").navigate("/chats");
  336. }
  337. // Owner-checked and status-checked: the row must belong to
  338. // this user's chat and still be pending (double clicks and
  339. // stale cards land here, not in two tasks).
  340. var suggestion = yield db.query<AdvisorSuggestion>()
  341. .where(expr("id == $0 && chat_id == $1 && user_id == $2",
  342. elem<int64?>(suggestion_id), elem<int64?>(chat_id),
  343. elem<int64?>(viewer_id)))
  344. .first_async();
  345. if (suggestion == null) {
  346. return directives().notify("error",
  347. "That suggestion does not exist").navigate("/chats");
  348. }
  349. if (((!)suggestion).status != "pending") {
  350. return notify_chat(chat_id,
  351. "That suggestion has already been decided");
  352. }
  353. var chat = yield chat_service.get_chat(chat_id, viewer_id);
  354. if (chat == null) {
  355. return directives().notify("error",
  356. "That chat no longer exists").navigate("/chats");
  357. }
  358. string notice;
  359. try {
  360. if (decision == "decline") {
  361. ((!)suggestion).status = "declined";
  362. ((!)suggestion).decided_at = new DateTime.now_local();
  363. yield db.update_async<AdvisorSuggestion>((!)suggestion);
  364. notice = "Declined.";
  365. } else if (((!)suggestion).kind == "task") {
  366. var payload = parse_payload(((!)suggestion).payload);
  367. string title = member_string(payload, "title");
  368. string mandate = member_string(payload, "mandate");
  369. string mode = member_string(payload, "mode");
  370. int64 project_id = member_int(payload, "project_id");
  371. int64 priority = member_int(payload, "priority");
  372. if (title.length == 0 || mandate.length == 0) {
  373. return notify_chat(chat_id,
  374. "That suggestion's payload is incomplete");
  375. }
  376. if (mode.length == 0) {
  377. mode = "implement";
  378. }
  379. if (!is_valid_mode(mode)) {
  380. return notify_chat(chat_id,
  381. @"Suggested mode '$(mode)' is not a valid work mode");
  382. }
  383. if (project_id < 0) {
  384. return notify_chat(chat_id,
  385. @"Invalid project id $(project_id)");
  386. }
  387. if (project_id > 0 && (yield project_exists(project_id)) == null) {
  388. return notify_chat(chat_id,
  389. @"No project exists with id $(project_id)");
  390. }
  391. // Creator is HUMAN — the creator-only enqueue rule
  392. // holds by construction and the approve_enqueue
  393. // path below enqueues as the human too.
  394. var task = yield task_service.create_task(
  395. project_id, title, mandate, priority,
  396. null, null, STATUS_DRAFT, TASK_KIND_TASK, mode,
  397. CREATOR_HUMAN, 0, 0);
  398. ((!)suggestion).task_id = task.id;
  399. ((!)suggestion).status = "approved";
  400. ((!)suggestion).decided_at = new DateTime.now_local();
  401. yield db.update_async<AdvisorSuggestion>((!)suggestion);
  402. if (decision == "approve_enqueue") {
  403. yield task_service.enqueue_task(task.id, CREATOR_HUMAN);
  404. notice = @"Approved & enqueued — task #$(task.id) queued";
  405. } else {
  406. notice = @"Approved — task #$(task.id) created (draft)";
  407. }
  408. } else {
  409. var payload = parse_payload(((!)suggestion).payload);
  410. string title = member_string(payload, "title");
  411. string brief = member_string(payload, "brief");
  412. string branch_name = member_string(payload, "branch_name");
  413. int64 project_id = member_int(payload, "project_id");
  414. if (title.length == 0 || brief.length == 0) {
  415. return notify_chat(chat_id,
  416. "That suggestion's payload is incomplete");
  417. }
  418. if (branch_name.length == 0) {
  419. branch_name = @"goal/advisor-$(suggestion_id)";
  420. }
  421. var project = yield project_exists(project_id);
  422. if (project_id <= 0 || project == null) {
  423. return notify_chat(chat_id,
  424. @"The goal needs an existing project (got $(project_id))");
  425. }
  426. // Draft only: the advisor NEVER starts a goal —
  427. // the human starts it from the goal page.
  428. var goal = yield goal_service.create_goal(
  429. project_id, title, brief, branch_name, null, 3,
  430. CREATOR_HUMAN);
  431. ((!)suggestion).goal_id = goal.id;
  432. ((!)suggestion).status = "approved";
  433. ((!)suggestion).decided_at = new DateTime.now_local();
  434. yield db.update_async<AdvisorSuggestion>((!)suggestion);
  435. notice = @"Approved — goal #$(goal.id) created in draft (not started)";
  436. }
  437. } catch (Error e) {
  438. return notify_chat(chat_id,
  439. @"Could not apply the decision: $(e.message)");
  440. }
  441. // The outcome lands in the chat and wakes the advisor so
  442. // it can acknowledge (and, on completion, follow up).
  443. try {
  444. yield chat_service.record_system_message(chat_id, notice);
  445. } catch (Error e) {
  446. warning("[ClankerSaddle] Advisor decision notice failed: %s",
  447. e.message);
  448. }
  449. advisor_service.run_turn.begin(chat_id, viewer_id);
  450. var updated = yield chat_service.get_chat(chat_id, viewer_id);
  451. if (updated == null) {
  452. return directives().navigate("/chats");
  453. }
  454. var state = yield ChatState.build_from(state_service, chat_service,
  455. advisor_service, action_registry, viewer_id, updated);
  456. return directives().set(state);
  457. }
  458. /** An error notify that stays on the chat page. */
  459. private DirectiveBuilder notify_chat(int64 chat_id, string message) {
  460. return directives().notify("error", message)
  461. .navigate(@"/chats/$(chat_id)");
  462. }
  463. /** The project row for `id`, or null (0 = standalone tasks). */
  464. private async ClankerSaddle.Data.Project? project_exists(int64 id)
  465. throws GLib.Error {
  466. if (id <= 0) {
  467. return null;
  468. }
  469. return yield db.query<ClankerSaddle.Data.Project>()
  470. .where(expr("id == $0", elem<int64?>(id)))
  471. .first_async();
  472. }
  473. private static Json.Object parse_payload(string payload) {
  474. var parser = new Json.Parser();
  475. try {
  476. parser.load_from_data(payload);
  477. } catch (Error e) {
  478. return new Json.Object();
  479. }
  480. var root = parser.get_root();
  481. if (root == null || root.get_node_type() != Json.NodeType.OBJECT) {
  482. return new Json.Object();
  483. }
  484. return ((!)root).get_object();
  485. }
  486. private static string member_string(Json.Object body, string member) {
  487. if (!body.has_member(member)) {
  488. return "";
  489. }
  490. var node = body.get_member(member);
  491. if (node.get_node_type() == Json.NodeType.VALUE
  492. && node.get_string() != null) {
  493. return (!)node.get_string();
  494. }
  495. return "";
  496. }
  497. private static int64 member_int(Json.Object body, string member) {
  498. if (!body.has_member(member)) {
  499. return 0;
  500. }
  501. var node = body.get_member(member);
  502. return node.get_node_type() == Json.NodeType.VALUE
  503. ? node.get_int() : 0;
  504. }
  505. }
  506. }