Merge pull request #103 from StampyAI/cleanup-search

Cleanup search
This commit is contained in:
Daniel O'Connell
2023-09-29 15:37:04 +02:00
committed by GitHub
3 changed files with 273 additions and 206 deletions
+223
View File
@@ -0,0 +1,223 @@
import { API_URL, STAMPY_URL, STAMPY_CONTENT_URL } from "../settings";
import type {
Citation,
Entry,
AssistantEntry,
ErrorMessage,
StampyMessage,
CurrentSearch,
Followup,
SearchResult,
} from "../types";
const MAX_FOLLOWUPS = 4;
const DATA_HEADER = "data: "
const EVENT_END_HEADER = "event: close\n"
type HistoryEntry = {
role: "error" | "stampy" | "assistant" | "user";
content: string;
};
export async function* iterateData(res: Response) {
const reader = res.body!.getReader();
var message = "";
while (true) {
const { done, value } = await reader.read();
if (done) return;
const chunk = new TextDecoder("utf-8").decode(value);
if (chunk.startsWith(EVENT_END_HEADER)) return;
for (const line of chunk.split("\n")) {
// Most times, it seems that a single read() call will be one SSE "message",
// but I'll do the proper aggregation spec thing in case that's not always true.
if (line.startsWith(DATA_HEADER)) {
message += line.slice(DATA_HEADER.length);
// Fixes #43
} else if (line !== "") {
message += line;
} else if (message !== "") {
yield JSON.parse(message);
message = "";
}
}
}
}
export const extractAnswer = async (
res: Response,
baseReferencesIndex: number,
setCurrent: (e: CurrentSearch) => void
): Promise<SearchResult> => {
var result: AssistantEntry = {
role: "assistant",
content: "",
citations: [],
base_count: baseReferencesIndex,
};
var followups: Followup[] = [];
for await (var data of iterateData(res)) {
switch (data.state) {
case "loading":
// display loading phases, once citations are available toss them
// into the current item.
result = {
...result,
citations: data?.citations || result?.citations || [],
};
setCurrent({ phase: data.phase, ...result });
break;
case "streaming":
// incrementally build up the response
result = {
role: "assistant",
content: (result?.content || "") + data.content,
citations: result?.citations || [],
base_count: result?.base_count || baseReferencesIndex,
};
setCurrent({ phase: "streaming", ...result });
break;
case "done":
// add any potential followup questions
const followups = Object.entries(data)
.filter(([key]) => key.startsWith("followup_"))
.map(([k, value]) => value as Followup);
return { result, followups };
case "error":
throw data.error;
}
}
return { result, followups };
};
const fetchLLM = async (
query: string,
mode: string,
history: HistoryEntry[]
): Promise<Response> =>
fetch(API_URL + "/chat", {
method: "POST",
cache: "no-cache",
keepalive: true,
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify({ query, mode, history }),
});
export const queryLLM = async (
query: string,
mode: string,
history: HistoryEntry[],
baseReferencesIndex: number,
setCurrent: (e?: CurrentSearch) => void
): Promise<SearchResult> => {
// do SSE on a POST request.
const res = await fetchLLM(query, mode, history);
if (!res.ok) {
return { result: { role: "error", content: "POST Error: " + res.status } };
}
try {
return await extractAnswer(res, baseReferencesIndex, setCurrent);
} catch (e) {
return {
result: { role: "error", content: e ? e.toString() : "unknown error" },
};
}
};
const cleanStampyContent = (contents: string) => contents.replace(
/<a(.*?)href="\/\?state=([a-zA-Z0-9]+.*?)"(.*?)<\/a>/g,
(_, pre, linkParts, post) => `<a${pre}href="${STAMPY_URL}/?state=${linkParts}"${post}</a>`
);
export const getStampyContent = async (
questionId: string
): Promise<SearchResult> => {
const res = await fetch(`${STAMPY_CONTENT_URL}/${questionId}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
});
if (!res.ok) {
return { result: { role: "error", content: "POST Error: " + res.status } };
}
const data = (await res.json()).data;
let result = {
role: "stampy",
content: cleanStampyContent(data.text),
url: `${STAMPY_URL}/?state=${data.pageid}`,
} as StampyMessage;
// re-enable the searchbox, with the question that was just answered
// removed from the list of possible followups.
// create an array of new followup questions from the data
const f_new = data.relatedQuestions.map((f: any) => ({
pageid: f.pageid!,
text: f.title!,
score: 0,
}));
const fpids = new Set(f_new.map((f: Followup) => f.pageid));
const followups = (f_old: Followup[]): Followup[] => {
const f_old_filtered = f_old.filter(
(f) => f.pageid !== data.pageid && !fpids.has(f.pageid)
);
return [...f_new, ...f_old_filtered].slice(0, MAX_FOLLOWUPS);
};
return { followups, result };
};
export const runSearch = async (
query: string,
query_source: "search" | "followups",
mode: string,
baseReferencesIndex: number,
entries: Entry[],
setCurrent: (c: CurrentSearch) => void
): SearchResult => {
if (query_source === "search") {
const history = entries
.filter((entry) => entry.role !== "error")
.map((entry) => ({
role: entry.role,
content: entry.content.trim(),
}));
return await queryLLM(
query,
mode,
history,
baseReferencesIndex,
setCurrent
);
} else {
// ----------------- HUMAN AUTHORED CONTENT RETRIEVAL ------------------
const [questionId] = query.split("\n", 2);
if (questionId) {
return await getStampyContent(questionId);
}
const result = {
role: "error",
content: "Could not extract Stampy id from " + query,
};
return { result } as SearchResult;
}
};
+49 -206
View File
@@ -5,6 +5,7 @@ import Image from 'next/image';
import Page from "../components/page"
import { API_URL } from "../settings"
import { queryLLM, getStampyContent, runSearch } from "../hooks/useSearch";
import type { Citation, Entry, UserEntry, AssistantEntry, ErrorMessage, StampyMessage } from "../types";
import { SearchBox, Followup } from "../components/searchbox";
import { GlossarySpan } from "../components/glossary";
@@ -32,18 +33,21 @@ type Mode = "rookie" | "concise" | "default";
// smooth-scroll to the bottom of the window if we're already less than 30% a screen away
// note: finicky interaction with "smooth" - maybe fix later.
function scroll30() {
if (document.documentElement.scrollHeight - window.scrollY > window.innerHeight * 1.3) return;
window.scrollTo({top: document.body.scrollHeight, behavior: "smooth"});
if (
document.documentElement.scrollHeight - window.scrollY >
window.innerHeight * 1.3
)
return;
window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" });
}
const Home: NextPage = () => {
const [ entries, setEntries ] = useState<Entry[]>([]);
const [ runningIndex, setRunningIndex ] = useState(0);
const [ loadState, setLoadState ] = useState<State>({state: "idle"});
const [entries, setEntries] = useState<Entry[]>([]);
const [runningIndex, setRunningIndex] = useState(0);
const [current, setCurrent] = useState<CurrentSearch>();
// [state, ready to save to localstorage]
const [ mode, setMode ] = useState<[Mode, boolean]>(["default", false]);
const [mode, setMode] = useState<[Mode, boolean]>(["default", false]);
// store mode in localstorage
useEffect(() => {
@@ -56,6 +60,12 @@ const Home: NextPage = () => {
setMode([mode, true]);
}, []);
const updateCurrent = (current: CurrentSearch) => {
setCurrent(current);
if (current?.phase === "streaming") {
scroll30();
}
};
const search = async (
query: string,
@@ -65,214 +75,47 @@ const Home: NextPage = () => {
) => {
// clear the query box, append to entries
const userEntry: Entry = {
role: "user",
content: query_source === "search" ? query : query.split("\n", 2)[1]!,
};
setEntries((prev) => [...prev, userEntry]);
disable();
const old_entries = entries;
const new_entries: Entry[] = [...old_entries, {
role: "user",
content: query_source === "search" ? query : query.split("\n", 2)[1]!,
}];
setEntries(new_entries);
disable();
const { result, followups } = await runSearch(
query,
query_source,
mode[0],
runningIndex,
entries,
updateCurrent
);
setCurrent(undefined);
// ----------------------------- LLM BASED -----------------------------
if (query_source === "search") {
// do SSE on a POST request.
const res = await fetch(API_URL + "/chat", {
method: "POST",
cache: "no-cache",
keepalive: true,
headers: {
"Content-Type": "application/json",
"Accept": "text/event-stream",
"Allow-Control-Allow-Origin": "*"
},
body: JSON.stringify({query: query, mode: mode[0], history:
old_entries.filter((entry) => entry.role !== "error")
.map((entry) => {
return {
"role" : entry.role,
"content" : entry.content.trim(),
}
})
}),
});
if (!res.ok) {
enable([]);
setLoadState({state: "idle"});
setEntries([...new_entries, {role: "error", content: "POST Error: " + res.status}]);
return;
if (query_source === "search") {
setRunningIndex(runningIndex + ProcessText(result.content, 0)[1].size);
}
// read back the SSE stream
const reader = res.body!.getReader();
var message = "";
var followups: Followup[] = [];
read: while (true) {
const {done, value} = await reader.read();
if (done) break;
const chunk = new TextDecoder("utf-8").decode(value);
if (chunk.startsWith("event: close\n")) break;
// note: this form isn't even remotely close to optimal in terms of
// network usage. Lots of json overhead.
for (const line of chunk.split('\n')) {
// Most times, it seems that a single read() call will be one SSE "message",
// but I'll do the proper aggregation spec thing in case that's not always true.
if (line.startsWith("data: ")) message += line.slice(6);
// Fixes #43
if (!line.startsWith("data: ") && line !== "") message += line;
if (line === "") {
if (message !== "") {
const data = JSON.parse(message);
switch (data.state) {
case "loading":
// display loading phases, once citations are available toss them
// into the loading state.
setLoadState((s) => {
var citations = s.state === "loading" ? s.citations : [];
if (data.citations !== undefined) {
citations = data.citations;
}
return {state: "loading", phase: data.phase, citations: citations};
});
break;
case "streaming":
// incrementally build up the response
setLoadState((s) => {
const response = s.state === "streaming" ? s.response :
{role: "assistant",
content: "",
citations: s.state === "loading" ? s.citations : [],
base_count: runningIndex
};
return {state: "streaming", response: {
role: "assistant",
content: response.content + data.content,
citations: response.citations,
base_count: response.base_count
}};
});
scroll30();
break;
case "done":
// append the response to the entries, reset to normal
setLoadState((s) => {
if (s.state === "streaming") {
setEntries([...new_entries, s.response]);
setRunningIndex((i) => (i + ProcessText(s.response.content, 0)[1].size));
}
return {state: "idle"};
});
// add any potential followup questions
var i = 0;
while ('followup_' + i in data) {
followups = [...followups, data['followup_' + i]];
i++;
}
break read;
case "error":
setEntries([...new_entries, {role: "error", content: data.error}]);
setLoadState({state: "idle"});
break read;
}
}
message = "";
}
}
}
enable(followups);
setEntries((prev) => [...prev, result]);
enable(followups || []);
scroll30();
} else {
// ----------------- HUMAN AUTHORED CONTENT RETRIEVAL ------------------
const query_id = query.split("\n", 2)[0];
const res = await fetch(API_URL + "/human/" + query_id, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"Allow-Control-Allow-Origin": "*"
},
});
if (!res.ok) {
enable([]);
setLoadState({state: "idle"});
setEntries([...new_entries, {role: "error", content: "POST Error: " + res.status}]);
return;
}
const data = (await res.json()).data;
setEntries([...new_entries, {
role: "stampy",
content: data.text,
url: "https://aisafety.info/?state=" + data.pageid,
}]);
// re-enable the searchbox, with the question that was just answered
// removed from the list of possible followups.
// create an array of new followup questions from the data
const f_new = data.relatedQuestions.map((f: any) => { return {
pageid: f.pageid!,
text: f.title!,
score: 0
};});
const fpids = new Set(f_new.map((f: Followup) => f.pageid));
enable((f_old: Followup[]) => {
const f_old_filtered = f_old.filter((f) => f.pageid !== data.pageid && !fpids.has(f.pageid));
return [...f_new, ...f_old_filtered].slice(0, MAX_FOLLOWUPS); // this is correct, it's N and not N-1 in javascript fsr
});
scroll30();
}
};
var last_entry = <></>;
if (loadState.state === "loading") {
switch (loadState.phase) {
case "semantic": last_entry = <p>Loading: Performing semantic search...</p>; break;
case "prompt": last_entry = <p>Loading: Creating prompt...</p>; break;
case "llm": last_entry = <p>Loading: Waiting for LLM...</p>; break;
}
} else if (loadState.state === "streaming") {
last_entry = <ShowAssistantEntry entry={loadState.response}/>;
switch (current?.phase) {
case "semantic":
last_entry = <p>Loading: Performing semantic search...</p>;
break;
case "prompt":
last_entry = <p>Loading: Creating prompt...</p>;
break;
case "llm":
last_entry = <p>Loading: Waiting for LLM...</p>;
break;
case "streaming":
last_entry = <ShowAssistantEntry entry={current} />;
break;
}
return (
<Page page="index">
<Controls mode={mode} setMode={setMode} />
+1
View File
@@ -1,2 +1,3 @@
export const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3001";
export const STAMPY_URL = process.env.STAMPY_URL || "https://aisafety.info"
export const STAMPY_CONTENT_URL = process.env.STAMPY_CONTENT_URL || `${API_URL}/human`