proposal-enrich-hosted.mjs
145 lines 5.5 KB
Raw
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