proposal-review-hints-async.mjs
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6
docs: record AIP-b SD-21 land (KN #308)
Human
9 days 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 | * HOSTED-WRITE-EVAL (2026-07-23): inline wait on create must leave headroom for Scooling's |
| 6 | * propose+approve abort (`HOSTED_REVIEW_WRITE_BACK_DEFAULT_TIMEOUT_MS` = 15_000). The previous |
| 7 | * 18_000 ms default caused production `/try` Approve timeouts. Personal self-apply (Scooling |
| 8 | * review-tray fingerprint) skips inline hints entirely — one-click approve follows create |
| 9 | * immediately, so Hub reviewer hints are not on that path. |
| 10 | */ |
| 11 | |
| 12 | import { completeChat } from '../../lib/llm-complete.mjs'; |
| 13 | import { SCOOLING_REVIEW_TRAY_INTENT } from '../../lib/hub-proposal-personal-self-apply.mjs'; |
| 14 | import { canisterAuthHeaders } from './canister-auth-headers.mjs'; |
| 15 | |
| 16 | /** |
| 17 | * Max ms the gateway may hold `POST /api/v1/proposals` waiting for review hints before returning. |
| 18 | * Kept well under Scooling's 15s shared Hub abort so create + approve can both finish. |
| 19 | */ |
| 20 | export const HOSTED_PROPOSAL_REVIEW_HINTS_INLINE_BUDGET_MS = 2000; |
| 21 | |
| 22 | /** |
| 23 | * Whether create-path inline hints should be skipped for this proposal class. |
| 24 | * Scooling personal self-apply creates are approved in the same client round trip — hints |
| 25 | * would race approve and are not the learner path (SD-18). |
| 26 | * |
| 27 | * @param {unknown} createBody - outgoing create JSON (after augment), if known |
| 28 | * @returns {boolean} |
| 29 | */ |
| 30 | export function shouldSkipInlineReviewHintsOnCreate(createBody) { |
| 31 | if (!createBody || typeof createBody !== 'object' || Buffer.isBuffer(createBody)) return false; |
| 32 | const intent = String(/** @type {Record<string, unknown>} */ (createBody).intent ?? '').trim(); |
| 33 | return intent === SCOOLING_REVIEW_TRAY_INTENT; |
| 34 | } |
| 35 | |
| 36 | /** |
| 37 | * Run LLM review hints inline (before response is sent), bounded by a deadline. |
| 38 | * setImmediate is not used because Netlify/Lambda containers freeze after the async handler |
| 39 | * resolves — macrotask callbacks never fire reliably in that environment. |
| 40 | * @param {{ |
| 41 | * method: string, |
| 42 | * pathOnly: string, |
| 43 | * upstreamStatus: number, |
| 44 | * responseText: string, |
| 45 | * canisterUrl: string, |
| 46 | * effectiveUserId: string, |
| 47 | * actorUserId: string, |
| 48 | * vaultId: string, |
| 49 | * hintsEnabled: boolean, |
| 50 | * proposalData?: { path: string, body: string } | null, |
| 51 | * createBody?: unknown, |
| 52 | * }} opts |
| 53 | * @param {number} [budgetMs=HOSTED_PROPOSAL_REVIEW_HINTS_INLINE_BUDGET_MS] Maximum ms to wait |
| 54 | * before giving up and letting the response proceed. |
| 55 | * @returns {Promise<void>} |
| 56 | */ |
| 57 | export async function maybeScheduleHostedProposalReviewHints( |
| 58 | opts, |
| 59 | budgetMs = HOSTED_PROPOSAL_REVIEW_HINTS_INLINE_BUDGET_MS, |
| 60 | ) { |
| 61 | if (!opts.hintsEnabled) return; |
| 62 | const { method, pathOnly, upstreamStatus, responseText, canisterUrl, effectiveUserId, actorUserId, vaultId } = opts; |
| 63 | if (method !== 'POST' || (pathOnly !== '/api/v1/proposals' && pathOnly !== '/api/v1/proposals/')) return; |
| 64 | if (upstreamStatus < 200 || upstreamStatus >= 300) return; |
| 65 | if (shouldSkipInlineReviewHintsOnCreate(opts.createBody)) return; |
| 66 | |
| 67 | let proposalId; |
| 68 | try { |
| 69 | const j = JSON.parse(responseText); |
| 70 | if (j && j.proposal_id) proposalId = String(j.proposal_id); |
| 71 | } catch (_) { |
| 72 | return; |
| 73 | } |
| 74 | if (!proposalId) return; |
| 75 | |
| 76 | const capped = |
| 77 | typeof budgetMs === 'number' && Number.isFinite(budgetMs) && budgetMs > 0 |
| 78 | ? Math.min(Math.floor(budgetMs), HOSTED_PROPOSAL_REVIEW_HINTS_INLINE_BUDGET_MS) |
| 79 | : HOSTED_PROPOSAL_REVIEW_HINTS_INLINE_BUDGET_MS; |
| 80 | |
| 81 | let timeoutHandle; |
| 82 | const deadline = new Promise((resolve) => { |
| 83 | timeoutHandle = setTimeout(() => resolve({ ok: false, code: 'TIMEOUT' }), capped); |
| 84 | }); |
| 85 | const job = runHostedProposalReviewHintsJob({ |
| 86 | canisterUrl, |
| 87 | effectiveUserId, |
| 88 | actorUserId, |
| 89 | vaultId, |
| 90 | proposalId, |
| 91 | proposalData: opts.proposalData || null, |
| 92 | }).catch((e) => ({ ok: false, code: 'RUNTIME_ERROR', detail: e?.message || String(e) })); |
| 93 | |
| 94 | const out = await Promise.race([job, deadline]); |
| 95 | clearTimeout(timeoutHandle); |
| 96 | if (!out.ok) { |
| 97 | console.error( |
| 98 | '[gateway] review hints failed', |
| 99 | JSON.stringify({ proposalId, code: out.code, detail: out.detail?.slice?.(0, 200) }), |
| 100 | ); |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | /** |
| 105 | * Run LLM review hints and POST to canister (used after proposal create and from explicit UI trigger). |
| 106 | * When proposalData is provided (path + body already known from the create response) the canister |
| 107 | * GET is skipped entirely, saving one ICP round trip (~1–3 s) and making it reliably fit inside |
| 108 | * the Netlify function budget. |
| 109 | * @param {{ |
| 110 | * canisterUrl: string, |
| 111 | * effectiveUserId: string, |
| 112 | * actorUserId: string, |
| 113 | * vaultId: string, |
| 114 | * proposalId: string, |
| 115 | * proposalData?: { path: string, body: string } | null, |
| 116 | * }} opts |
| 117 | * @returns {Promise<{ ok: true } | { ok: false, status: number, code: string, detail?: string }>} |
| 118 | */ |
| 119 | export async function runHostedProposalReviewHintsJob({ |
| 120 | canisterUrl, |
| 121 | effectiveUserId, |
| 122 | actorUserId, |
| 123 | vaultId, |
| 124 | proposalId, |
| 125 | proposalData = null, |
| 126 | }) { |
| 127 | const base = canisterUrl.replace(/\/$/, ''); |
| 128 | const h = { |
| 129 | Accept: 'application/json', |
| 130 | 'x-user-id': effectiveUserId, |
| 131 | 'x-actor-id': actorUserId, |
| 132 | 'x-vault-id': vaultId, |
| 133 | ...canisterAuthHeaders(), |
| 134 | }; |
| 135 | const miniConfig = { |
| 136 | embedding: { ollama_url: process.env.OLLAMA_URL }, |
| 137 | llm: {}, |
| 138 | }; |
| 139 | |
| 140 | let proposalPath, proposalBody; |
| 141 | if (proposalData && proposalData.path != null && proposalData.body) { |
| 142 | proposalPath = String(proposalData.path); |
| 143 | proposalBody = String(proposalData.body); |
| 144 | } else { |
| 145 | let getRes; |
| 146 | try { |
| 147 | getRes = await fetch(`${base}/api/v1/proposals/${encodeURIComponent(proposalId)}`, { headers: h }); |
| 148 | } catch (e) { |
| 149 | return { ok: false, status: 502, code: 'UPSTREAM', detail: `fetch: ${e?.message || String(e)}` }; |
| 150 | } |
| 151 | if (!getRes.ok) { |
| 152 | const t = await getRes.text().catch(() => ''); |
| 153 | return { |
| 154 | ok: false, |
| 155 | status: getRes.status === 404 ? 404 : 502, |
| 156 | code: 'UPSTREAM', |
| 157 | detail: (t && t.slice(0, 500)) || `GET proposal ${getRes.status}`, |
| 158 | }; |
| 159 | } |
| 160 | let p; |
| 161 | try { |
| 162 | p = await getRes.json(); |
| 163 | } catch (e) { |
| 164 | return { |
| 165 | ok: false, |
| 166 | status: 502, |
| 167 | code: 'UPSTREAM_JSON', |
| 168 | detail: `Canister returned non-JSON body for hints proposal ${proposalId}: ${e?.message || String(e)}`, |
| 169 | }; |
| 170 | } |
| 171 | if (!p || p.status !== 'proposed') { |
| 172 | return { ok: false, status: 400, code: 'BAD_REQUEST', detail: 'Can only attach hints to proposed proposals' }; |
| 173 | } |
| 174 | proposalPath = p.path; |
| 175 | proposalBody = p.body || ''; |
| 176 | } |
| 177 | |
| 178 | const system = |
| 179 | '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.'; |
| 180 | const user = `Path: ${proposalPath}\n---\n${String(proposalBody).slice(0, 12_000)}`; |
| 181 | let raw; |
| 182 | try { |
| 183 | raw = await completeChat(miniConfig, { system, user, maxTokens: 400 }); |
| 184 | } catch (e) { |
| 185 | const msg = e && e.message ? String(e.message) : String(e); |
| 186 | return { ok: false, status: 500, code: 'RUNTIME_ERROR', detail: msg }; |
| 187 | } |
| 188 | const model = process.env.OPENAI_API_KEY |
| 189 | ? process.env.OPENAI_CHAT_MODEL || 'gpt-4o-mini' |
| 190 | : process.env.ANTHROPIC_API_KEY |
| 191 | ? process.env.ANTHROPIC_CHAT_MODEL || 'claude-3-5-haiku-20241022' |
| 192 | : process.env.OLLAMA_CHAT_MODEL || process.env.OLLAMA_MODEL || 'ollama'; |
| 193 | const postRes = await fetch(`${base}/api/v1/proposals/${encodeURIComponent(proposalId)}/review-hints`, { |
| 194 | method: 'POST', |
| 195 | headers: { ...h, 'Content-Type': 'application/json' }, |
| 196 | body: JSON.stringify({ |
| 197 | review_hints: raw.slice(0, 8000), |
| 198 | review_hints_model: String(model).slice(0, 128), |
| 199 | }), |
| 200 | }); |
| 201 | if (!postRes.ok) { |
| 202 | const t = await postRes.text(); |
| 203 | return { |
| 204 | ok: false, |
| 205 | status: postRes.status >= 400 && postRes.status < 600 ? postRes.status : 502, |
| 206 | code: 'CANISTER_HINTS', |
| 207 | detail: t.slice(0, 500), |
| 208 | }; |
| 209 | } |
| 210 | return { ok: true }; |
| 211 | } |
File History
1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6
docs: record AIP-b SD-21 land (KN #308)
Human
9 days ago