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