proposal-review-hints-async.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
1 day ago
| 1 | /** |
| 2 | * After successful hosted proposal create, optionally run LLM and POST review-hints to canister. |
| 3 | * Env: KNOWTATION_HUB_PROPOSAL_REVIEW_HINTS=1. Model output is untrusted; not a merge gate. |
| 4 | */ |
| 5 | |
| 6 | import { completeChat } from '../../lib/llm-complete.mjs'; |
| 7 | import { canisterAuthHeaders } from './canister-auth-headers.mjs'; |
| 8 | |
| 9 | /** |
| 10 | * Run LLM review hints inline (before response is sent), bounded by a deadline. |
| 11 | * setImmediate is not used because Netlify/Lambda containers freeze after the async handler |
| 12 | * resolves — macrotask callbacks never fire reliably in that environment. |
| 13 | * @param {{ |
| 14 | * method: string, |
| 15 | * pathOnly: string, |
| 16 | * upstreamStatus: number, |
| 17 | * responseText: string, |
| 18 | * canisterUrl: string, |
| 19 | * effectiveUserId: string, |
| 20 | * actorUserId: string, |
| 21 | * vaultId: string, |
| 22 | * hintsEnabled: boolean, |
| 23 | * proposalData?: { path: string, body: string } | null, |
| 24 | * }} opts |
| 25 | * @param {number} [budgetMs=18000] Maximum ms to wait before giving up and letting the response proceed. |
| 26 | * @returns {Promise<void>} |
| 27 | */ |
| 28 | export async function maybeScheduleHostedProposalReviewHints(opts, budgetMs = 18000) { |
| 29 | if (!opts.hintsEnabled) return; |
| 30 | const { method, pathOnly, upstreamStatus, responseText, canisterUrl, effectiveUserId, actorUserId, vaultId } = opts; |
| 31 | if (method !== 'POST' || (pathOnly !== '/api/v1/proposals' && pathOnly !== '/api/v1/proposals/')) return; |
| 32 | if (upstreamStatus < 200 || upstreamStatus >= 300) return; |
| 33 | let proposalId; |
| 34 | try { |
| 35 | const j = JSON.parse(responseText); |
| 36 | if (j && j.proposal_id) proposalId = String(j.proposal_id); |
| 37 | } catch (_) { |
| 38 | return; |
| 39 | } |
| 40 | if (!proposalId) return; |
| 41 | |
| 42 | let timeoutHandle; |
| 43 | const deadline = new Promise((resolve) => { |
| 44 | timeoutHandle = setTimeout(() => resolve({ ok: false, code: 'TIMEOUT' }), budgetMs); |
| 45 | }); |
| 46 | const job = runHostedProposalReviewHintsJob({ |
| 47 | canisterUrl, |
| 48 | effectiveUserId, |
| 49 | actorUserId, |
| 50 | vaultId, |
| 51 | proposalId, |
| 52 | proposalData: opts.proposalData || null, |
| 53 | }).catch((e) => ({ ok: false, code: 'RUNTIME_ERROR', detail: e?.message || String(e) })); |
| 54 | |
| 55 | const out = await Promise.race([job, deadline]); |
| 56 | clearTimeout(timeoutHandle); |
| 57 | if (!out.ok) { |
| 58 | console.error( |
| 59 | '[gateway] review hints failed', |
| 60 | JSON.stringify({ proposalId, code: out.code, detail: out.detail?.slice?.(0, 200) }), |
| 61 | ); |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | /** |
| 66 | * Run LLM review hints and POST to canister (used after proposal create and from explicit UI trigger). |
| 67 | * When proposalData is provided (path + body already known from the create response) the canister |
| 68 | * GET is skipped entirely, saving one ICP round trip (~1–3 s) and making it reliably fit inside |
| 69 | * the Netlify function budget. |
| 70 | * @param {{ |
| 71 | * canisterUrl: string, |
| 72 | * effectiveUserId: string, |
| 73 | * actorUserId: string, |
| 74 | * vaultId: string, |
| 75 | * proposalId: string, |
| 76 | * proposalData?: { path: string, body: string } | null, |
| 77 | * }} opts |
| 78 | * @returns {Promise<{ ok: true } | { ok: false, status: number, code: string, detail?: string }>} |
| 79 | */ |
| 80 | export async function runHostedProposalReviewHintsJob({ |
| 81 | canisterUrl, |
| 82 | effectiveUserId, |
| 83 | actorUserId, |
| 84 | vaultId, |
| 85 | proposalId, |
| 86 | proposalData = null, |
| 87 | }) { |
| 88 | const base = canisterUrl.replace(/\/$/, ''); |
| 89 | const h = { |
| 90 | Accept: 'application/json', |
| 91 | 'x-user-id': effectiveUserId, |
| 92 | 'x-actor-id': actorUserId, |
| 93 | 'x-vault-id': vaultId, |
| 94 | ...canisterAuthHeaders(), |
| 95 | }; |
| 96 | const miniConfig = { |
| 97 | embedding: { ollama_url: process.env.OLLAMA_URL }, |
| 98 | llm: {}, |
| 99 | }; |
| 100 | |
| 101 | let proposalPath, proposalBody; |
| 102 | if (proposalData && proposalData.path != null && proposalData.body) { |
| 103 | proposalPath = String(proposalData.path); |
| 104 | proposalBody = String(proposalData.body); |
| 105 | } else { |
| 106 | let getRes; |
| 107 | try { |
| 108 | getRes = await fetch(`${base}/api/v1/proposals/${encodeURIComponent(proposalId)}`, { headers: h }); |
| 109 | } catch (e) { |
| 110 | return { ok: false, status: 502, code: 'UPSTREAM', detail: `fetch: ${e?.message || String(e)}` }; |
| 111 | } |
| 112 | if (!getRes.ok) { |
| 113 | const t = await getRes.text().catch(() => ''); |
| 114 | return { |
| 115 | ok: false, |
| 116 | status: getRes.status === 404 ? 404 : 502, |
| 117 | code: 'UPSTREAM', |
| 118 | detail: (t && t.slice(0, 500)) || `GET proposal ${getRes.status}`, |
| 119 | }; |
| 120 | } |
| 121 | let p; |
| 122 | try { |
| 123 | p = await getRes.json(); |
| 124 | } catch (e) { |
| 125 | return { |
| 126 | ok: false, |
| 127 | status: 502, |
| 128 | code: 'UPSTREAM_JSON', |
| 129 | detail: `Canister returned non-JSON body for hints proposal ${proposalId}: ${e?.message || String(e)}`, |
| 130 | }; |
| 131 | } |
| 132 | if (!p || p.status !== 'proposed') { |
| 133 | return { ok: false, status: 400, code: 'BAD_REQUEST', detail: 'Can only attach hints to proposed proposals' }; |
| 134 | } |
| 135 | proposalPath = p.path; |
| 136 | proposalBody = p.body || ''; |
| 137 | } |
| 138 | |
| 139 | const system = |
| 140 | 'You assist human proposal reviewers. Reply with plain text only: 2–6 short lines (risks, unclear scope, things to verify). Do not say pass/fail or approve; output is untrusted hints.'; |
| 141 | const user = `Path: ${proposalPath}\n---\n${String(proposalBody).slice(0, 12_000)}`; |
| 142 | let raw; |
| 143 | try { |
| 144 | raw = await completeChat(miniConfig, { system, user, maxTokens: 400 }); |
| 145 | } catch (e) { |
| 146 | const msg = e && e.message ? String(e.message) : String(e); |
| 147 | return { ok: false, status: 500, code: 'RUNTIME_ERROR', detail: msg }; |
| 148 | } |
| 149 | const model = process.env.OPENAI_API_KEY |
| 150 | ? process.env.OPENAI_CHAT_MODEL || 'gpt-4o-mini' |
| 151 | : process.env.ANTHROPIC_API_KEY |
| 152 | ? process.env.ANTHROPIC_CHAT_MODEL || 'claude-3-5-haiku-20241022' |
| 153 | : process.env.OLLAMA_CHAT_MODEL || process.env.OLLAMA_MODEL || 'ollama'; |
| 154 | const postRes = await fetch(`${base}/api/v1/proposals/${encodeURIComponent(proposalId)}/review-hints`, { |
| 155 | method: 'POST', |
| 156 | headers: { ...h, 'Content-Type': 'application/json' }, |
| 157 | body: JSON.stringify({ |
| 158 | review_hints: raw.slice(0, 8000), |
| 159 | review_hints_model: String(model).slice(0, 128), |
| 160 | }), |
| 161 | }); |
| 162 | if (!postRes.ok) { |
| 163 | const t = await postRes.text(); |
| 164 | return { |
| 165 | ok: false, |
| 166 | status: postRes.status >= 400 && postRes.status < 600 ? postRes.status : 502, |
| 167 | code: 'CANISTER_HINTS', |
| 168 | detail: t.slice(0, 500), |
| 169 | }; |
| 170 | } |
| 171 | return { ok: true }; |
| 172 | } |
File History
2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠
1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6
docs: accept Calendar Events v0 spec with Phase 0 security …
Human
1 day ago