proposal-enrich-hosted.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
1 day ago
| 1 | /** |
| 2 | * Hosted Enrich: call LLM then POST assistant fields to canister. |
| 3 | * Env: KNOWTATION_HUB_PROPOSAL_ENRICH=1. Output is advisory; not a merge gate. |
| 4 | */ |
| 5 | |
| 6 | import { completeChat } from '../../lib/llm-complete.mjs'; |
| 7 | import { validateAndNormalizeEnrichResult, serializeSuggestedFrontmatterJson } from '../../lib/proposal-enrich-llm.mjs'; |
| 8 | import { canisterAuthHeaders } from './canister-auth-headers.mjs'; |
| 9 | |
| 10 | function miniLlmConfig() { |
| 11 | return { |
| 12 | embedding: { ollama_url: process.env.OLLAMA_URL }, |
| 13 | llm: {}, |
| 14 | }; |
| 15 | } |
| 16 | |
| 17 | function chatModelLabel() { |
| 18 | const provider = String(process.env.KNOWTATION_CHAT_PROVIDER || '').trim().toLowerCase(); |
| 19 | const hasOpenai = Boolean(process.env.OPENAI_API_KEY); |
| 20 | const hasAnthropic = Boolean(process.env.ANTHROPIC_API_KEY); |
| 21 | const hasDeepinfra = Boolean(process.env.DEEPINFRA_API_KEY); |
| 22 | if (provider === 'deepinfra' || (hasDeepinfra && !hasOpenai && !hasAnthropic)) { |
| 23 | return process.env.DEEPINFRA_CHAT_MODEL || 'Qwen/Qwen2.5-72B-Instruct'; |
| 24 | } |
| 25 | if (provider === 'openai' && hasOpenai) { |
| 26 | return process.env.OPENAI_CHAT_MODEL || 'gpt-4o-mini'; |
| 27 | } |
| 28 | if (provider === 'anthropic' && hasAnthropic) { |
| 29 | return process.env.ANTHROPIC_CHAT_MODEL || 'claude-3-5-haiku-20241022'; |
| 30 | } |
| 31 | if (hasOpenai) { |
| 32 | return process.env.OPENAI_CHAT_MODEL || 'gpt-4o-mini'; |
| 33 | } |
| 34 | if (hasAnthropic) { |
| 35 | return process.env.ANTHROPIC_CHAT_MODEL || 'claude-3-5-haiku-20241022'; |
| 36 | } |
| 37 | return process.env.OLLAMA_CHAT_MODEL || process.env.OLLAMA_MODEL || 'ollama'; |
| 38 | } |
| 39 | |
| 40 | function buildHostedEnrichMessages(input) { |
| 41 | const path = input.path != null ? String(input.path) : ''; |
| 42 | const intent = input.intent != null ? String(input.intent) : '—'; |
| 43 | const body = input.body != null ? String(input.body).slice(0, 12_000) : ''; |
| 44 | const system = |
| 45 | 'Reply with ONLY valid JSON (no markdown fences): {"summary":"one short paragraph","suggested_labels":["short-tag"],"suggested_frontmatter":{"title":"...","project":"...","tags":["..."],"date":"...","updated":"...","source":"...","source_id":"...","intent":"...","follows":"inbox/note.md","causal_chain_id":"...","entity":"...","episode_id":"...","summarizes":"inbox/other.md","summarizes_range":"...","state_snapshot":true}}. suggested_frontmatter is optional; include only fields clearly grounded in the content. Labels use lowercase slug form.'; |
| 46 | const user = `Path: ${path}\nIntent: ${intent}\n---\n${body}`; |
| 47 | return { system, user }; |
| 48 | } |
| 49 | |
| 50 | /** |
| 51 | * @param {{ |
| 52 | * canisterUrl: string, |
| 53 | * effectiveUserId: string, |
| 54 | * actorUserId: string, |
| 55 | * vaultId: string, |
| 56 | * proposalId: string, |
| 57 | * enrichEnabled: boolean, |
| 58 | * }} opts |
| 59 | * @returns {Promise<{ ok: true } | { ok: false, status: number, code: string, detail?: string }>} |
| 60 | */ |
| 61 | export async function runHostedProposalEnrichAndPost(opts) { |
| 62 | if (!opts.enrichEnabled) { |
| 63 | return { ok: false, status: 404, code: 'NOT_FOUND' }; |
| 64 | } |
| 65 | const { canisterUrl, effectiveUserId, actorUserId, vaultId, proposalId } = opts; |
| 66 | const base = canisterUrl.replace(/\/$/, ''); |
| 67 | const h = { |
| 68 | Accept: 'application/json', |
| 69 | 'x-user-id': effectiveUserId, |
| 70 | 'x-actor-id': actorUserId, |
| 71 | 'x-vault-id': vaultId, |
| 72 | ...canisterAuthHeaders(), |
| 73 | }; |
| 74 | let getRes; |
| 75 | try { |
| 76 | getRes = await fetch(`${base}/api/v1/proposals/${encodeURIComponent(proposalId)}`, { headers: h }); |
| 77 | } catch (e) { |
| 78 | return { ok: false, status: 502, code: 'UPSTREAM', detail: `fetch: ${e?.message || String(e)}` }; |
| 79 | } |
| 80 | if (!getRes.ok) { |
| 81 | const t = await getRes.text().catch(() => ''); |
| 82 | return { |
| 83 | ok: false, |
| 84 | status: getRes.status === 404 ? 404 : 502, |
| 85 | code: 'UPSTREAM', |
| 86 | detail: t ? t.slice(0, 500) : undefined, |
| 87 | }; |
| 88 | } |
| 89 | let p; |
| 90 | try { |
| 91 | p = await getRes.json(); |
| 92 | } catch (e) { |
| 93 | return { |
| 94 | ok: false, |
| 95 | status: 502, |
| 96 | code: 'UPSTREAM_JSON', |
| 97 | detail: `Canister returned non-JSON body for proposal ${proposalId}: ${e?.message || String(e)}`, |
| 98 | }; |
| 99 | } |
| 100 | if (!p || p.status !== 'proposed') { |
| 101 | return { ok: false, status: 400, code: 'BAD_REQUEST', detail: 'Can only enrich proposed proposals' }; |
| 102 | } |
| 103 | |
| 104 | // Hosted runs inside a short-lived Netlify function, so keep the prompt/output budget |
| 105 | // close to the last known good path while still returning the expanded schema. |
| 106 | const { system, user } = buildHostedEnrichMessages({ |
| 107 | path: p.path, |
| 108 | intent: p.intent, |
| 109 | body: p.body, |
| 110 | }); |
| 111 | let raw; |
| 112 | try { |
| 113 | raw = await completeChat(miniLlmConfig(), { system, user, maxTokens: 400 }); |
| 114 | } catch (e) { |
| 115 | const msg = e && e.message ? String(e.message) : String(e); |
| 116 | return { ok: false, status: 500, code: 'RUNTIME_ERROR', detail: msg }; |
| 117 | } |
| 118 | |
| 119 | const norm = validateAndNormalizeEnrichResult(raw); |
| 120 | const model = chatModelLabel(); |
| 121 | const labelsJson = JSON.stringify( |
| 122 | norm.suggested_labels.map((x) => String(x).slice(0, 64)).filter(Boolean).slice(0, 8), |
| 123 | ); |
| 124 | const fmJson = serializeSuggestedFrontmatterJson(norm.suggested_frontmatter); |
| 125 | const postRes = await fetch(`${base}/api/v1/proposals/${encodeURIComponent(proposalId)}/enrich`, { |
| 126 | method: 'POST', |
| 127 | headers: { ...h, 'Content-Type': 'application/json' }, |
| 128 | body: JSON.stringify({ |
| 129 | assistant_notes: String(norm.summary).slice(0, 16_000), |
| 130 | assistant_model: String(model).slice(0, 128), |
| 131 | suggested_labels_json: labelsJson, |
| 132 | assistant_suggested_frontmatter_json: fmJson, |
| 133 | }), |
| 134 | }); |
| 135 | if (!postRes.ok) { |
| 136 | const t = await postRes.text(); |
| 137 | return { |
| 138 | ok: false, |
| 139 | status: postRes.status >= 400 && postRes.status < 600 ? postRes.status : 502, |
| 140 | code: 'CANISTER_ENRICH', |
| 141 | detail: t.slice(0, 500), |
| 142 | }; |
| 143 | } |
| 144 | return { ok: true }; |
| 145 | } |
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