Spaces:
Sleeping
Sleeping
File size: 3,636 Bytes
843f9e1 72d5cd7 843f9e1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 | import {
COORDINATOR_EMAIL,
KB_VERSION,
LLM_URL,
MAX_HISTORY_MESSAGES,
MAX_OUTPUT_TOKENS,
MODEL_ID,
NOT_IN_NOTES,
} from "./constants.js";
import { redact } from "./redact.js";
import { retrieve } from "./retrieve.js";
import { buildSystem } from "./systemPrompt.js";
const KIND = { sheet: "coordinator's written answers", ama: "AMA recording", policy: "SIWES policy" };
function sourcesFor(segments) {
return segments.map(({ seg }) => ({
id: seg.id,
title: seg.title,
kinds: [...new Set([...seg.facts.map((f) => f.src), ...(seg.faqs.length ? ["sheet"] : [])])].map(
(k) => KIND[k] || k,
),
}));
}
export function noNotes() {
return {
mode: "none",
answer: `${NOT_IN_NOTES}.\n\n- Email the SIWES coordinator: **${COORDINATOR_EMAIL}**\n- Or ask your success advisor\n- Put your own details in that email, not in this chat`,
sources: [],
kbVersion: KB_VERSION,
};
}
export function offlineAnswer(found) {
const { segments, bestFaq } = found;
if (!segments.length) return noNotes();
const top = segments[0].seg;
const parts = [];
if (bestFaq) {
parts.push(bestFaq.faq.a);
} else {
parts.push(top.blurb);
if (top.steps.length) parts.push(top.steps.map((s) => `- ${s}`).join("\n"));
}
parts.push(`_Confirm dates and exceptions with the coordinator: ${COORDINATOR_EMAIL}_`);
const used = bestFaq ? [{ seg: bestFaq.seg }] : [{ seg: top }];
return { mode: "offline", answer: parts.join("\n\n"), sources: sourcesFor(used), kbVersion: KB_VERSION };
}
async function callModel(system, messages, apiKey) {
const res = await fetch(LLM_URL, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
model: MODEL_ID,
max_tokens: MAX_OUTPUT_TOKENS,
temperature: 0.3,
reasoning_effort: "low",
messages: [{ role: "system", content: system }, ...messages],
}),
signal: AbortSignal.timeout(20_000),
});
if (!res.ok) throw new Error(`model ${res.status}`);
const data = await res.json();
return (data.choices?.[0]?.message?.content ?? "").trim();
}
export function cleanHistory(history) {
if (!Array.isArray(history)) return [];
return history
.filter((m) => m && (m.role === "user" || m.role === "assistant") && typeof m.content === "string")
.slice(-MAX_HISTORY_MESSAGES)
.map((m) => ({ role: m.role, content: redact(m.content).slice(0, 1500) }));
}
export async function answer({ index, message, history = [], llm = callModel, apiKey = process.env.GROQ_API_KEY }) {
const clean = redact(message);
const prior = cleanHistory(history);
// Retrieve on the new message plus the last student turn so follow-ups ("and for SIWES II?") still match.
const lastUser = [...prior].reverse().find((m) => m.role === "user");
const found = retrieve(index, lastUser ? `${lastUser.content} ${clean}` : clean);
const own = retrieve(index, clean);
const useFound = own.segments.length ? own : found;
if (!apiKey) return offlineAnswer(useFound);
if (!useFound.segments.length) return noNotes();
try {
const system = buildSystem(useFound.segments, index.kb.glossary);
const text = await llm(system, [...prior, { role: "user", content: clean }], apiKey);
if (!text) return offlineAnswer(useFound);
const missing = text.toLowerCase().includes(NOT_IN_NOTES.toLowerCase());
return {
mode: "ai",
answer: text,
sources: missing ? [] : sourcesFor(useFound.segments),
kbVersion: KB_VERSION,
};
} catch {
return { ...offlineAnswer(useFound), degraded: true };
}
}
|