proposal-review-hints-async.mjs
211 lines 7.8 KB
Raw
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