Sync CourtListener verification and document safety updates

- Refine CourtListener citation verification, bulk lookup logging, and API fallback behavior
- Persist cancelled chat stream output and render cancellation as the final assistant message
- Add document/version deletion safety fixes and shared warning/modal UI updates
- Sync document panel, case law panel, and response UI styling refinements
- Harden OSS sync script to preserve local env, dependency, and generated files
This commit is contained in:
willchen96 2026-06-09 01:46:58 +08:00
parent 44e868eb42
commit f32a194b33
24 changed files with 2494 additions and 1222 deletions

View file

@ -100,6 +100,9 @@ export type ChatMessage = {
export const SYSTEM_PROMPT = `You are Mike, an AI legal assistant that helps lawyers and legal professionals analyze documents, answer legal questions, and draft legal documents.
TOOL BUDGET:
You have at most 10 tool-use rounds in a single response. Use tools deliberately, batch independent tool calls in the same round where possible, and reserve enough room to produce a final answer. Do not spend the final tool round gathering more information unless you can answer without another tool call afterward.
DOCUMENT CITATION INSTRUCTIONS:
When you reference specific content from an uploaded/generated document, place a numbered marker [1], [2], etc. inline in your prose at the point of reference.
These numbered [N] markers and the <CITATIONS> block are for evidence passages that the UI can open. Uploaded/generated document citations use the document entry shape below. Research tools may define additional source-specific citation entry shapes in their own instructions.
@ -1972,7 +1975,6 @@ type CourtlistenerCaseRecord = {
url: string | null;
pdfUrl: string | null;
dateFiled: string | null;
judges: string | null;
opinions?: unknown[];
};
@ -1984,7 +1986,6 @@ type CourtlistenerCaseInput = {
url?: string | null;
pdfUrl?: string | null;
dateFiled?: string | null;
judges?: string | null;
opinions?: unknown[];
};
@ -2015,7 +2016,6 @@ function upsertCourtlistenerCases(
url: null,
pdfUrl: null,
dateFiled: null,
judges: null,
};
const nextCitations = [
...current.citations,
@ -2031,7 +2031,6 @@ function upsertCourtlistenerCases(
url: current.url ?? nonEmpty(input.url),
pdfUrl: current.pdfUrl ?? nonEmpty(input.pdfUrl),
dateFiled: current.dateFiled ?? nonEmpty(input.dateFiled),
judges: current.judges ?? nonEmpty(input.judges),
opinions: current.opinions ?? input.opinions,
};
state.casesByClusterId.set(clusterId, record);
@ -2052,7 +2051,6 @@ function caseCitationEventFromRecord(
url: record.url,
pdfUrl: record.pdfUrl,
dateFiled: record.dateFiled,
judges: record.judges,
};
}
@ -2104,7 +2102,6 @@ function courtlistenerCaseInputFromFetchedCase(
url: stringField(record, "url"),
pdfUrl: stringField(record, "pdfUrl"),
dateFiled: stringField(record, "dateFiled"),
judges: stringField(record, "judges"),
opinions: Array.isArray(record?.opinions) ? record.opinions : undefined,
};
}
@ -2146,7 +2143,6 @@ function courtlistenerFetchedCaseMetadata(
dateFiled: record.dateFiled,
url: record.url,
pdfUrl: record.pdfUrl,
judges: record.judges,
opinion_count: opinionCount,
opinions: (record.opinions ?? [])
.map(courtlistenerOpinionMetadata)
@ -2884,7 +2880,6 @@ export async function runToolCalls(
citations: record.citations,
url: record.url,
dateFiled: record.dateFiled,
judges: record.judges,
opinion_count: opinions.length,
opinions: (record.opinions ?? [])
.map(courtlistenerOpinionMetadata)
@ -2933,7 +2928,6 @@ export async function runToolCalls(
citations: record.citations,
url: record.url,
dateFiled: record.dateFiled,
judges: record.judges,
opinion_count: opinions.length,
returned_opinion_count: selectedOpinions.length,
opinions: selectedOpinions,
@ -2944,16 +2938,13 @@ export async function runToolCalls(
? args.citations.filter(
(value): value is string => typeof value === "string",
)
: undefined;
const citationCount =
citations?.length ??
(typeof args.text === "string" && args.text.trim() ? 1 : 0);
: [];
const citationCount = citations.length;
write(
`data: ${JSON.stringify({ type: "courtlistener_verify_citations_start", citation_count: citationCount })}\n\n`,
);
try {
const result = (await verifyCourtlistenerCitations({
text: typeof args.text === "string" ? args.text : undefined,
citations,
db,
apiToken: apiKeys?.courtlistener,
@ -2964,7 +2955,6 @@ export async function runToolCalls(
caseName?: string | null;
dateFiled?: string | null;
pdfUrl?: string | null;
judges?: string | null;
url?: string | null;
markdown?: string;
}[];
@ -2983,7 +2973,6 @@ export async function runToolCalls(
url: link.url,
pdfUrl: link.pdfUrl,
dateFiled: link.dateFiled,
judges: link.judges,
})),
);
const recordsByClusterId = new Map(
@ -3712,7 +3701,6 @@ function createCitationAnnotation(
url: caseRecord?.url ?? null,
pdfUrl: caseRecord?.pdfUrl ?? null,
dateFiled: caseRecord?.dateFiled ?? null,
judges: caseRecord?.judges ?? null,
quotes: citation.quotes,
};
}
@ -3812,6 +3800,13 @@ export class AssistantStreamError extends Error {
}
}
export class AssistantStreamAbortError extends AssistantStreamError {
constructor(fullText: string, events: AssistantEvent[]) {
super("Stream aborted.", fullText, events);
this.name = "AbortError";
}
}
export function isAbortError(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const record = error as { name?: unknown; message?: unknown };
@ -3970,22 +3965,25 @@ export async function runLLMStream(params: {
}
};
const flushVisibleTail = () => {
const flushVisibleTail = (opts: { emit?: boolean } = {}) => {
const emit = opts.emit ?? true;
if (citationsOpenSeen || !visibleTailBuffer) {
visibleTailBuffer = "";
return;
}
iterVisibleText += visibleTailBuffer;
write(
`data: ${JSON.stringify({ type: "content_delta", text: visibleTailBuffer })}\n\n`,
);
if (emit) {
write(
`data: ${JSON.stringify({ type: "content_delta", text: visibleTailBuffer })}\n\n`,
);
}
visibleTailBuffer = "";
};
const flushText = () => {
const flushText = (opts: { emit?: boolean } = {}) => {
if (!iterText) return;
fullText += iterText;
flushVisibleTail();
flushVisibleTail(opts);
if (iterVisibleText) {
events.push({ type: "content", text: iterVisibleText });
}
@ -3997,6 +3995,14 @@ export async function runLLMStream(params: {
streamedCitationCount = 0;
};
const flushPartialTurn = (opts: { emit?: boolean } = {}) => {
flushText(opts);
if (iterReasoning) {
events.push({ type: "reasoning", text: iterReasoning });
iterReasoning = "";
}
};
const selectedModel = resolveModel(model, DEFAULT_MAIN_MODEL);
try {
@ -4161,8 +4167,11 @@ export async function runLLMStream(params: {
},
});
} catch (err) {
if (isAbortError(err)) throw err;
flushText();
if (isAbortError(err)) {
flushPartialTurn({ emit: false });
throw new AssistantStreamAbortError(fullText, events);
}
flushPartialTurn();
const message =
err instanceof Error && err.message ? err.message : "Stream error";
events.push({ type: "error", message });
@ -4208,6 +4217,24 @@ export function stripTransientAssistantEvents(events: AssistantEvent[]) {
return events.filter((event) => event.type !== "case_opinions");
}
export function appendCancelledAssistantEvent(events: AssistantEvent[]) {
return [...events, { type: "content" as const, text: "Cancelled by user." }];
}
export function buildCancelledAssistantMessage(args: {
fullText: string;
events: AssistantEvent[];
buildAnnotations: (fullText: string, events: AssistantEvent[]) => unknown[];
}) {
const events = appendCancelledAssistantEvent(
stripTransientAssistantEvents(args.events),
);
return {
events,
annotations: args.buildAnnotations(args.fullText, events),
};
}
// ---------------------------------------------------------------------------
// Document context builder (from message file attachments)
// ---------------------------------------------------------------------------

View file

@ -90,6 +90,7 @@ async function courtlistenerFetch<T>(
});
const response = await fetch(url, {
...init,
signal: init?.signal ?? AbortSignal.timeout(15_000),
headers: {
...courtlistenerHeaders(apiToken),
...(init?.headers ?? {}),
@ -146,7 +147,6 @@ function compactCluster(raw: unknown) {
id: null,
caseName: null,
dateFiled: null,
judges: null,
court: null,
citations: [],
url: null,
@ -161,7 +161,6 @@ function compactCluster(raw: unknown) {
asString(cluster.caseName) ??
asString(cluster.name),
dateFiled: asString(cluster.date_filed) ?? asString(cluster.dateFiled),
judges: asString(cluster.judges),
court:
asString((cluster.docket as JsonRecord | undefined)?.court_id) ??
asString(cluster.court) ??
@ -208,14 +207,19 @@ async function fetchCaseOpinionsFromCourtlistenerOpinionsEndpoint(args: {
includeFullText?: boolean;
apiToken?: string | null;
}) {
const MAX_OPINION_PAGES = 10;
const opinions: ReturnType<typeof compactOpinion>[] = [];
const rawOpinions: JsonRecord[] = [];
let nextUrl: string | null = `/opinions/?cluster=${args.clusterId}`;
let pages = 0;
let remainingChars = args.maxChars;
while (nextUrl) {
while (nextUrl && pages < MAX_OPINION_PAGES && remainingChars > 0) {
pages += 1;
devLog("[courtlistener/opinions-endpoint] fetching page", {
clusterId: args.clusterId,
path: nextUrl,
page: pages,
});
const data = await courtlistenerFetch<JsonRecord>(
nextUrl,
@ -226,21 +230,26 @@ async function fetchCaseOpinionsFromCourtlistenerOpinionsEndpoint(args: {
const opinionMaxChars = args.includeFullText
? Math.max(
500,
Math.floor(args.maxChars / Math.max(1, results.length)),
Math.floor(remainingChars / Math.max(1, results.length)),
)
: 3000;
: Math.min(3000, remainingChars);
const pageOpinions = results.filter(
(opinion): opinion is JsonRecord =>
!!opinion &&
typeof opinion === "object" &&
!Array.isArray(opinion),
);
rawOpinions.push(...pageOpinions);
opinions.push(
...pageOpinions.map((opinion) =>
compactOpinion(opinion, opinionMaxChars),
),
);
for (const opinion of pageOpinions) {
if (remainingChars <= 0) break;
const compacted = compactOpinion(
opinion,
Math.max(1, Math.min(opinionMaxChars, remainingChars)),
);
rawOpinions.push(opinion);
opinions.push(compacted);
remainingChars -=
(compacted.text?.length ?? 0) + (compacted.html?.length ?? 0);
}
nextUrl = asString(data.next);
}
@ -481,7 +490,6 @@ function compactBulkCluster(cluster: JsonRecord, citations: string[] = []) {
asString(cluster.case_name_full) ??
asString(cluster.case_name_short),
dateFiled: asString(cluster.date_filed),
judges: asString(cluster.judges),
court: null,
citations,
url: clusterUrl(cluster),
@ -490,29 +498,132 @@ function compactBulkCluster(cluster: JsonRecord, citations: string[] = []) {
};
}
type CitationLookupCluster =
| ReturnType<typeof compactCluster>
| ReturnType<typeof compactBulkCluster>;
type CitationLookupRow = {
citation: string | null;
status: string;
message: string | null;
clusters: CitationLookupCluster[];
};
type CitationLookupPayload = {
citationsSubmitted?: number;
citationLinks: {
clusterId: number | null;
citation: string | null;
caseName: string | null;
court: string | null;
dateFiled: string | null;
pdfUrl: string | null;
url: string | null;
markdown: string;
}[];
results: CitationLookupRow[];
source?: string;
};
function buildCitationLinks(results: CitationLookupRow[]) {
return results.flatMap((result) =>
result.clusters.flatMap((cluster) => {
if (!cluster.url) return [];
const label = [cluster.caseName, result.citation]
.filter(Boolean)
.join(", ");
return [
{
clusterId: cluster.id,
citation: result.citation,
caseName: cluster.caseName,
court: cluster.court,
dateFiled: cluster.dateFiled,
pdfUrl: cluster.pdfUrl,
url: cluster.url,
markdown: `[${label || cluster.url}](${cluster.url})`,
},
];
}),
);
}
function courtlistenerApiTokenAvailable(apiToken?: string | null) {
return !!(apiToken?.trim() || process.env.COURTLISTENER_API_TOKEN?.trim());
}
async function getBulkCitationLookup(args: {
db?: ServerSupabase;
citations: string[];
}) {
if (!args.db || !courtlistenerBulkDataEnabled()) return null;
allowPartial?: boolean;
}): Promise<CitationLookupPayload | null> {
const parsed = args.citations.map((citation) => ({
citation,
parts: parseCitationParts(citation),
}));
if (!parsed.length || parsed.some((row) => !row.parts)) return null;
devLog("[courtlistener/bulk-citation-lookup] candidates", {
enabled: courtlistenerBulkDataEnabled(),
hasDb: !!args.db,
allowPartial: !!args.allowPartial,
count: parsed.length,
candidates: parsed.map((row) => ({
citation: row.citation,
parsed: row.parts
? {
volume: row.parts.volume,
reporter: row.parts.reporter,
page: row.parts.page,
}
: null,
})),
});
if (!args.db || !courtlistenerBulkDataEnabled()) return null;
if (!parsed.length) return null;
if (!args.allowPartial && parsed.some((row) => !row.parts)) {
devLog("[courtlistener/bulk-citation-lookup] skipped", {
reason: "unparseable_candidate",
unparseable: parsed
.filter((row) => !row.parts)
.map((row) => row.citation),
});
return null;
}
const results: {
citation: string | null;
status: string;
message: string | null;
clusters: ReturnType<typeof compactBulkCluster>[];
}[] = [];
const results: CitationLookupRow[] = [];
for (const row of parsed) {
const parts = row.parts;
if (!parts) return null;
if (!parts) {
devLog("[courtlistener/bulk-citation-lookup] skipped candidate", {
citation: row.citation,
reason: "unparseable_candidate",
});
if (!args.allowPartial) return null;
results.push({
citation: row.citation,
status: "invalid",
message: "Citation could not be parsed for bulk lookup.",
clusters: [],
});
continue;
}
const verifiedCitation = citationPartsLabel(parts);
if (!verifiedCitation) return null;
if (!verifiedCitation) {
if (!args.allowPartial) return null;
results.push({
citation: row.citation,
status: "invalid",
message: "Citation could not be normalized for bulk lookup.",
clusters: [],
});
continue;
}
devLog("[courtlistener/bulk-citation-lookup] citation query", {
citation: row.citation,
volume: parts.volume,
reporter: parts.reporter,
page: parts.page,
});
const { data: citationRows, error } = await args.db
.from("courtlistener_citation_index")
.select("cluster_id, volume, reporter, page")
@ -520,7 +631,21 @@ async function getBulkCitationLookup(args: {
.eq("reporter", parts.reporter)
.eq("page", parts.page)
.limit(20);
if (error) return null;
devLog("[courtlistener/bulk-citation-lookup] citation query result", {
citation: row.citation,
rowCount: citationRows?.length ?? 0,
error: error?.message ?? null,
});
if (error) {
if (!args.allowPartial) return null;
results.push({
citation: verifiedCitation,
status: "error",
message: error.message,
clusters: [],
});
continue;
}
const clusterIds = [
...new Set(
(citationRows ?? [])
@ -532,15 +657,43 @@ async function getBulkCitationLookup(args: {
.filter((id) => Number.isFinite(id)),
),
];
if (!clusterIds.length) return null;
if (!clusterIds.length) {
if (!args.allowPartial) return null;
results.push({
citation: verifiedCitation,
status: "not_found",
message: "Citation was not found in the bulk citation index.",
clusters: [],
});
continue;
}
devLog("[courtlistener/bulk-citation-lookup] cluster query", {
citation: row.citation,
clusterIds,
});
const { data: clusters, error: clusterError } = await args.db
.from("courtlistener_opinion_cluster_index")
.select(
"id, case_name, case_name_short, case_name_full, slug, date_filed, judges, filepath_pdf_harvard",
"id, case_name, case_name_short, case_name_full, slug, date_filed, filepath_pdf_harvard",
)
.in("id", clusterIds);
if (clusterError) return null;
devLog("[courtlistener/bulk-citation-lookup] cluster query result", {
citation: row.citation,
requestedCount: clusterIds.length,
rowCount: clusters?.length ?? 0,
error: clusterError?.message ?? null,
});
if (clusterError) {
if (!args.allowPartial) return null;
results.push({
citation: verifiedCitation,
status: "error",
message: clusterError.message,
clusters: [],
});
continue;
}
const clustersById = new Map(
(clusters ?? [])
.map((cluster) => {
@ -567,7 +720,16 @@ async function getBulkCitationLookup(args: {
(cluster): cluster is ReturnType<typeof compactBulkCluster> =>
!!cluster && !!cluster.caseName,
);
if (matchedClusters.length !== clusterIds.length) return null;
if (matchedClusters.length !== clusterIds.length) {
if (!args.allowPartial) return null;
results.push({
citation: verifiedCitation,
status: matchedClusters.length ? "partial" : "not_found",
message: "Some citation clusters were missing from the bulk cluster index.",
clusters: matchedClusters,
});
continue;
}
results.push({
citation: verifiedCitation,
@ -577,37 +739,62 @@ async function getBulkCitationLookup(args: {
});
}
const citationLinks = results.flatMap((result) =>
result.clusters.flatMap((cluster) => {
if (!cluster.url) return [];
const label = [cluster.caseName, result.citation]
.filter(Boolean)
.join(", ");
return [
{
clusterId: cluster.id,
citation: result.citation,
caseName: cluster.caseName,
court: cluster.court,
dateFiled: cluster.dateFiled,
judges: cluster.judges,
pdfUrl: cluster.pdfUrl,
url: cluster.url,
markdown: `[${label || cluster.url}](${cluster.url})`,
},
];
}),
);
const payload = {
citationsSubmitted: args.citations.length || undefined,
citationLinks,
citationLinks: buildCitationLinks(results),
results,
source: "bulk",
};
return payload;
}
async function fetchCourtlistenerCitationLookup(args: {
text: string;
citationsSubmitted?: number;
apiToken?: string | null;
}): Promise<CitationLookupPayload> {
const body = new URLSearchParams();
body.set("text", args.text.slice(0, 64000));
const results = await courtlistenerFetch<unknown[]>(
"/citation-lookup/",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body,
},
args.apiToken,
);
const compactResults: CitationLookupRow[] = (Array.isArray(results)
? results
: []
)
.map((item) => {
if (!item || typeof item !== "object") return null;
const row = item as JsonRecord;
return {
citation:
asString(row.citation) ??
asString(row.normalized_citation) ??
null,
status: asString(row.status) ?? String(row.status ?? "unknown"),
message: asString(row.message),
clusters: Array.isArray(row.clusters)
? row.clusters.map(compactCluster)
: [],
};
})
.filter((row): row is CitationLookupRow => !!row);
return {
citationsSubmitted: args.citationsSubmitted,
citationLinks: buildCitationLinks(compactResults),
results: compactResults,
};
}
async function getBulkCourtlistenerCaseOpinions(args: {
db?: ServerSupabase;
clusterId: number;
@ -697,7 +884,7 @@ async function getBulkCourtlistenerCaseOpinions(args: {
const { data: cluster, error } = await args.db
.from("courtlistener_opinion_cluster_index")
.select(
"id, case_name, case_name_short, case_name_full, slug, date_filed, judges, filepath_pdf_harvard",
"id, case_name, case_name_short, case_name_full, slug, date_filed, filepath_pdf_harvard",
)
.eq("id", args.clusterId)
.maybeSingle();
@ -778,7 +965,6 @@ async function getBulkCourtlistenerCaseOpinions(args: {
}
export async function verifyCourtlistenerCitations(args: {
text?: string;
citations?: string[];
db?: ServerSupabase;
apiToken?: string | null;
@ -789,85 +975,80 @@ export async function verifyCourtlistenerCitations(args: {
.filter(Boolean)
.slice(0, 250)
: [];
const text =
typeof args.text === "string" && args.text.trim()
? args.text.trim()
: citations.join("\n");
if (!text) {
return { error: "Provide text or at least one citation." };
if (!citations.length) {
return { error: "Provide at least one citation or case name." };
}
const bulkCandidates = citations;
const bulk = await getBulkCitationLookup({
db: args.db,
citations: citations.length
? citations
: text.split(/\n+/).filter(Boolean),
citations: bulkCandidates,
allowPartial: true,
});
if (bulk) return bulk;
const body = new URLSearchParams();
body.set("text", text.slice(0, 64000));
const results = await courtlistenerFetch<unknown[]>(
"/citation-lookup/",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body,
},
args.apiToken,
);
const compactResults = (Array.isArray(results) ? results : []).map(
(item) => {
if (!item || typeof item !== "object") return item;
const row = item as JsonRecord;
return {
citation:
asString(row.citation) ??
asString(row.normalized_citation) ??
null,
status: row.status ?? null,
message: asString(row.message),
clusters: Array.isArray(row.clusters)
? row.clusters.map(compactCluster)
: [],
};
},
);
const citationLinks = compactResults.flatMap((result) => {
if (!result || typeof result !== "object") return [];
const row = result as {
citation?: string | null;
clusters?: ReturnType<typeof compactCluster>[];
};
return (row.clusters ?? []).flatMap((cluster) => {
if (!cluster.url) return [];
const label = [cluster.caseName, row.citation]
.filter(Boolean)
.join(", ");
return [
{
clusterId: cluster.id,
citation: row.citation ?? null,
caseName: cluster.caseName,
court: cluster.court,
dateFiled: cluster.dateFiled,
judges: cluster.judges,
pdfUrl: cluster.pdfUrl,
url: cluster.url,
markdown: `[${label || cluster.url}](${cluster.url})`,
},
];
});
});
return {
devLog("[courtlistener/bulk-citation-lookup] result", {
hit: !!bulk,
citationsSubmitted: citations.length || undefined,
citationLinks,
results: compactResults,
};
candidateCount: bulkCandidates.length,
resultCount: Array.isArray(bulk?.results) ? bulk.results.length : 0,
citationLinkCount: Array.isArray(bulk?.citationLinks)
? bulk.citationLinks.length
: 0,
statuses: Array.isArray(bulk?.results)
? bulk.results.map((result) => result.status)
: [],
source: bulk?.source ?? null,
});
if (bulk) {
const apiFallbackInputs =
citations.length > 0 && courtlistenerApiTokenAvailable(args.apiToken)
? bulk.results
.filter(
(result) =>
result.status === "not_found" ||
result.status === "invalid",
)
.map((result) => result.citation)
.filter((citation): citation is string => !!citation)
: [];
if (!apiFallbackInputs.length) return bulk;
devLog("[courtlistener/bulk-citation-lookup] api fallback", {
candidateCount: apiFallbackInputs.length,
candidates: apiFallbackInputs,
});
try {
const apiFallback = await fetchCourtlistenerCitationLookup({
text: apiFallbackInputs.join("\n"),
citationsSubmitted: apiFallbackInputs.length,
apiToken: args.apiToken,
});
const fallbackRows = [...apiFallback.results];
const mergedResults = bulk.results.flatMap((result) => {
if (result.status !== "not_found" && result.status !== "invalid") {
return [result];
}
return [fallbackRows.shift() ?? result];
});
mergedResults.push(...fallbackRows);
return {
citationsSubmitted: bulk.citationsSubmitted,
citationLinks: buildCitationLinks(mergedResults),
results: mergedResults,
source: "bulk+api",
};
} catch (err) {
devLog("[courtlistener/bulk-citation-lookup] api fallback failed", {
error: err instanceof Error ? err.message : String(err),
});
return bulk;
}
}
return fetchCourtlistenerCitationLookup({
text: citations.join("\n"),
citationsSubmitted: citations.length || undefined,
apiToken: args.apiToken,
});
}
export async function searchCourtlistenerCaseLaw(args: {

View file

@ -59,7 +59,6 @@ export type CaseCitationEvent = {
url: string;
pdfUrl?: string | null;
dateFiled?: string | null;
judges?: string | null;
};
export const COURTLISTENER_TOOL_NAMES = {
@ -72,10 +71,9 @@ export const COURTLISTENER_TOOL_NAMES = {
export const COURTLISTENER_SYSTEM_PROMPT = `LEGAL RESEARCH QUERIES:
- When a user asks a question on US law, you are required to cite relevant case law in your answer. Always verify US case citations using the courtlistener_verify_citations tool.
- If the user gives case names or reporter citations, use courtlistener_verify_citations for those names/citations.
- CourtListener keyword/issue search is not available. Do not attempt to search CourtListener for new candidate cases by legal issue or keywords. Work only from cases/citations supplied by the user, cases found in the provided documents, or citations already present in the conversation.
- The courtlistener_verify_citations tool accepts only a citations array of clean reporter citations. Do not pass case names to this tool. Correct: {"citations":["467 U.S. 837","323 U.S. 134"]}. Incorrect: {"citations":["Chevron U.S.A. v. NRDC","Skidmore v. Swift"]}. If you only have case names and no reporter citations, do not call courtlistener_verify_citations for those names.
- If any CourtListener tool call reports that a CourtListener rate limit was exceeded, or returns a 429/throttled/rate-limit error, do not make any further CourtListener API/search calls in that turn. Do not retry, verify more citations, fetch more cases, or run additional CourtListener searches; answer with the information already available and briefly state that CourtListener is rate limiting requests.
- For cases you may cite or materially rely on, follow this sequence: first use courtlistener_verify_citations for case names/citations, then use courtlistener_get_cases to fetch/cache the relevant case clusters, then use courtlistener_find_in_case to search targeted keywords in the cached opinions, and only if those keyword snippets are insufficient use courtlistener_read_case to read selected opinion text.
- For cases you may cite or materially rely on, follow this sequence when reporter citations are available: first use courtlistener_verify_citations with clean reporter citations, then use courtlistener_get_cases to fetch/cache the relevant case clusters, then use courtlistener_find_in_case to search targeted keywords in the cached opinions, and only if those keyword snippets are insufficient use courtlistener_read_case to read selected opinion text.
- Only cite cases whose underlying opinion text, or at least the specific relevant opinion passages, has been supplied to you in this turn. courtlistener_get_cases only fetches and caches opinions; it does NOT place full opinion text in your context. It returns text-free opinion metadata so you can choose which opinion(s) matter. After courtlistener_get_cases, use courtlistener_find_in_case for targeted keyword or phrase lookup inside that cached case. If those snippets are not enough, use courtlistener_read_case to read only the specific already-fetched opinion(s) you need. courtlistener_find_in_case and courtlistener_read_case require the case to have been fetched first.
- When a fetched case has multiple opinions, do not read all opinions by default. Choose the specific opinion_id or opinion_ids needed from the metadata or search hits. Prefer the lead/majority/controlling opinion when it is sufficient; read concurrences, dissents, or combined opinions only when they are necessary for the user's question.
- When using courtlistener_find_in_case, search for terms that are 1-3 words long and actually likely to appear exactly as written in the opinion text. Do not use long sentence-like phrases. Run courtlistener_find_in_case no more than 3 times in a single assistant turn; if those searches are insufficient, read the smallest needed opinion text with courtlistener_read_case or answer with the available information.
@ -175,22 +173,18 @@ export const COURTLISTENER_TOOLS = [
function: {
name: COURTLISTENER_TOOL_NAMES.verifyCitations,
description:
"Verify legal case citations using CourtListener's citation lookup. Accepts raw text containing citations, or multiple citation strings. This returns citation metadata and clickable case refs; call courtlistener_get_cases only for matched cases that need full opinion text.",
"Verify legal case citations using CourtListener's citation lookup. Accepts only an array of clean reporter citations, not case names. Example: {\"citations\":[\"467 U.S. 837\",\"323 U.S. 134\"]}. This returns citation metadata and clickable case refs; call courtlistener_get_cases only for matched cases that need full opinion text.",
parameters: {
type: "object",
properties: {
text: {
type: "string",
description:
"Raw text containing one or more legal citations. Max 64,000 characters sent to CourtListener.",
},
citations: {
type: "array",
items: { type: "string" },
description:
"Optional list of citation strings. Up to 250 will be joined into the request text field.",
"Required list of clean reporter citations only. Put each reporter citation in its own array item, e.g. [\"467 U.S. 837\", \"323 U.S. 134\"]. Do not include case names. Up to 250 items.",
},
},
required: ["citations"],
},
},
},

View file

@ -5,12 +5,7 @@ export function logRawLlmStream(args: {
label: string;
payload: unknown;
}) {
if (
process.env.NODE_ENV === "production" &&
process.env.LOG_RAW_LLM_STREAM !== "true"
) {
return;
}
if (process.env.LOG_RAW_LLM_STREAM !== "true") return;
console.log(
`[raw-llm-stream:${args.provider}:${args.model}:iter-${args.iteration}] ${args.label}`,

View file

@ -30,23 +30,24 @@ const PROVIDERS: ApiKeyProvider[] = [
];
function envApiKey(provider: ApiKeyProvider): string | null {
if (provider === "claude") {
return (
process.env.ANTHROPIC_API_KEY?.trim() ||
process.env.CLAUDE_API_KEY?.trim() ||
null
);
switch (provider) {
case "claude":
return (
process.env.ANTHROPIC_API_KEY?.trim() ||
process.env.CLAUDE_API_KEY?.trim() ||
null
);
case "gemini":
return process.env.GEMINI_API_KEY?.trim() || null;
case "openai":
return process.env.OPENAI_API_KEY?.trim() || null;
case "openrouter":
return process.env.OPENROUTER_API_KEY?.trim() || null;
case "courtlistener":
return process.env.COURTLISTENER_API_TOKEN?.trim() || null;
default:
return null;
}
if (provider === "openai") {
return process.env.OPENAI_API_KEY?.trim() || null;
}
if (provider === "openrouter") {
return process.env.OPENROUTER_API_KEY?.trim() || null;
}
if (provider === "courtlistener") {
return process.env.COURTLISTENER_API_TOKEN?.trim() || null;
}
return process.env.GEMINI_API_KEY?.trim() || null;
}
export function hasEnvApiKey(provider: ApiKeyProvider): boolean {
@ -58,7 +59,7 @@ function encryptionKey(): Buffer {
if (!secret) {
throw new Error("USER_API_KEYS_ENCRYPTION_SECRET is not configured");
}
return crypto.createHash("sha256").update(secret).digest();
return crypto.scryptSync(secret, "mike-user-api-keys-v1", 32);
}
function encrypt(value: string): Omit<EncryptedKeyRow, "provider"> {

View file

@ -44,7 +44,7 @@ caseLawRouter.post("/case-opinions", async (req, res) => {
clusterId,
});
const db = createServerSupabase();
const fetchKey = String(clusterId);
const fetchKey = `${userId}:${clusterId}`;
let fetchPromise = sidepanelOpinionFetches.get(fetchKey);
if (fetchPromise) {
devLog("[case-law/case-opinions] joining in-flight fetch", {

View file

@ -7,6 +7,7 @@ import {
enrichWithPriorEvents,
buildWorkflowStore,
AssistantStreamError,
buildCancelledAssistantMessage,
extractAnnotations,
isAbortError,
runLLMStream,
@ -614,6 +615,28 @@ chatRouter.post("/", requireAuth, async (req, res) => {
} catch (err) {
if (isAbortError(err)) {
devLog("[chat/stream] client aborted stream", { chatId });
if (err instanceof AssistantStreamError) {
const partial = buildCancelledAssistantMessage({
fullText: err.fullText,
events: err.events,
buildAnnotations: (fullText, events) =>
extractAnnotations(fullText, docIndex, events),
});
const { error: saveError } = await db.from("chat_messages").insert({
chat_id: chatId,
role: "assistant",
content: partial.events.length ? partial.events : null,
annotations: partial.annotations.length
? partial.annotations
: null,
});
if (saveError) {
console.error(
"[chat/stream] failed to save aborted stream",
saveError,
);
}
}
return;
}
console.error("[chat/stream] error:", err);

View file

@ -423,6 +423,15 @@ documentsRouter.post(
const sourceAccess = await ensureDocAccess(sourceDoc, userId, userEmail, db);
if (!sourceAccess.ok)
return void res.status(404).json({ detail: "Source document not found" });
const willDeleteSource =
sourceDoc.project_id &&
targetDoc.project_id &&
sourceDoc.project_id === targetDoc.project_id;
if (willDeleteSource && !sourceAccess.isOwner) {
return void res.status(403).json({
detail: "Only the source document owner can move it into a version.",
});
}
const targetActive = await loadActiveVersion(documentId, db);
const targetType = targetActive?.file_type ?? "";
@ -548,11 +557,7 @@ documentsRouter.post(
.json({ detail: "Failed to update document current version." });
}
if (
sourceDoc.project_id &&
targetDoc.project_id &&
sourceDoc.project_id === targetDoc.project_id
) {
if (willDeleteSource) {
const { error: deleteErr } = await deleteDocumentAndVersionFiles(
db,
sourceDocumentId,
@ -721,12 +726,21 @@ documentsRouter.post(
.json({ detail: "Failed to record new version." });
}
await db
const { error: updateDocErr } = await db
.from("documents")
.update({
current_version_id: versionRow.id,
})
.eq("id", documentId);
if (updateDocErr) {
console.error(
"[versions/upload] current version update failed",
updateDocErr,
);
return void res
.status(500)
.json({ detail: "Failed to update document current version." });
}
res.status(201).json(versionRow);
},

View file

@ -7,6 +7,7 @@ import {
buildWorkflowStore,
enrichWithPriorEvents,
AssistantStreamError,
buildCancelledAssistantMessage,
extractAnnotations,
isAbortError,
runLLMStream,
@ -199,6 +200,28 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
console.log("[project-chat/stream] client aborted stream", {
chatId,
});
if (err instanceof AssistantStreamError) {
const partial = buildCancelledAssistantMessage({
fullText: err.fullText,
events: err.events,
buildAnnotations: (fullText, events) =>
extractAnnotations(fullText, docIndex, events),
});
const { error: saveError } = await db.from("chat_messages").insert({
chat_id: chatId,
role: "assistant",
content: partial.events.length ? partial.events : null,
annotations: partial.annotations.length
? partial.annotations
: null,
});
if (saveError) {
console.error(
"[project-chat/stream] failed to save aborted stream",
saveError,
);
}
}
return;
}
console.error("[project-chat/stream] error:", err);

View file

@ -28,6 +28,37 @@ function normalizeDocumentFilename(nextName: unknown, currentName: string) {
return `${trimmed}${ext}`;
}
async function deleteProjectDocumentsAndVersionFiles(
db: ReturnType<typeof createServerSupabase>,
projectId: string,
documentIds: string[],
) {
if (documentIds.length === 0) return null;
const { data: versions, error: versionsError } = await db
.from("document_versions")
.select("storage_path, pdf_storage_path")
.in("document_id", documentIds);
if (versionsError) return versionsError;
const paths = new Set<string>();
for (const v of versions ?? []) {
if (typeof v.storage_path === "string" && v.storage_path.length > 0) {
paths.add(v.storage_path);
}
if (typeof v.pdf_storage_path === "string" && v.pdf_storage_path.length > 0) {
paths.add(v.pdf_storage_path);
}
}
await Promise.all([...paths].map((p) => deleteFile(p).catch(() => {})));
const { error } = await db
.from("documents")
.delete()
.eq("project_id", projectId)
.in("id", documentIds);
return error ?? null;
}
// GET /projects
projectsRouter.get("/", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
@ -710,11 +741,48 @@ projectsRouter.delete("/:projectId/folders/:folderId", requireAuth, async (req,
const access = await checkProjectAccess(projectId, userId, userEmail, db);
if (!access.ok) return void res.status(404).json({ detail: "Project not found" });
const folder = await loadProjectFolder(db, projectId, folderId);
if (!folder) return void res.status(404).json({ detail: "Folder not found" });
const { data: allFolders, error: foldersError } = await db
.from("project_subfolders")
.select("id, parent_folder_id")
.eq("project_id", projectId);
if (foldersError)
return void res.status(500).json({ detail: foldersError.message });
if (!(allFolders ?? []).some((f) => f.id === folderId))
return void res.status(404).json({ detail: "Folder not found" });
// Move direct documents to root before cascade-deleting subfolders
await db.from("documents").update({ folder_id: null }).eq("folder_id", folderId).eq("project_id", projectId);
const childrenByParent = new Map<string, string[]>();
for (const f of allFolders ?? []) {
const parentId = f.parent_folder_id as string | null;
if (!parentId) continue;
const children = childrenByParent.get(parentId) ?? [];
children.push(f.id as string);
childrenByParent.set(parentId, children);
}
const folderIds = new Set<string>();
const stack = [folderId];
while (stack.length > 0) {
const id = stack.pop()!;
if (folderIds.has(id)) continue;
folderIds.add(id);
stack.push(...(childrenByParent.get(id) ?? []));
}
const { data: docs, error: docsError } = await db
.from("documents")
.select("id")
.eq("project_id", projectId)
.in("folder_id", [...folderIds]);
if (docsError) return void res.status(500).json({ detail: docsError.message });
const docIds = (docs ?? []).map((d) => d.id as string);
const deleteDocsError = await deleteProjectDocumentsAndVersionFiles(
db,
projectId,
docIds,
);
if (deleteDocsError)
return void res.status(500).json({ detail: deleteDocsError.message });
const { error } = await db.from("project_subfolders")
.delete().eq("id", folderId).eq("project_id", projectId);

View file

@ -9,6 +9,7 @@ import {
import { normalizeDocxZipPaths } from "../lib/convert";
import {
AssistantStreamError,
buildCancelledAssistantMessage,
isAbortError,
runLLMStream,
stripTransientAssistantEvents,
@ -480,8 +481,6 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => {
const { reviewId } = req.params;
const updates: Record<string, unknown> = {};
if (req.body.title != null) updates.title = req.body.title;
if (req.body.columns_config != null)
updates.columns_config = req.body.columns_config;
const projectIdUpdateProvided = req.body.project_id !== undefined;
const projectIdUpdate =
req.body.project_id === null
@ -534,6 +533,14 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => {
);
if (!access.ok)
return void res.status(404).json({ detail: "Review not found" });
if (req.body.columns_config != null) {
if (!access.isOwner) {
return void res.status(403).json({
detail: "Only the review owner can change columns",
});
}
updates.columns_config = req.body.columns_config;
}
if (sharedWithUpdate !== undefined) {
if (!access.isOwner)
return void res
@ -1365,8 +1372,9 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => {
messages.filter((m) => m.role === "user").length === 1;
if (chatId) {
// Either chat owner OR any project member of the parent review can
// continue the chat. We've already verified review access above.
// The chat must belong to this exact review and to the requester.
// Review access alone is not enough: otherwise a user could reuse one
// of their chats from a different review in this route.
const { data: existing } = await db
.from("tabular_review_chats")
.select("id, title, review_id, user_id")
@ -1374,7 +1382,8 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => {
.single();
const canUse =
!!existing &&
(existing.review_id === reviewId || existing.user_id === userId);
existing.review_id === reviewId &&
existing.user_id === userId;
if (!canUse || !existing) chatId = null;
else chatTitle = existing.title;
}
@ -1479,6 +1488,34 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => {
} catch (err) {
if (isAbortError(err)) {
console.log("[tabular/chat] client aborted stream", { chatId });
if (chatId && err instanceof AssistantStreamError) {
const partial = buildCancelledAssistantMessage({
fullText: err.fullText,
events: err.events,
buildAnnotations: (fullText) =>
extractTabularAnnotations(fullText, tabularStore),
});
const { error: saveError } = await db
.from("tabular_review_chat_messages")
.insert({
chat_id: chatId,
role: "assistant",
content: partial.events.length ? partial.events : null,
annotations: partial.annotations.length
? partial.annotations
: null,
});
if (saveError) {
console.error(
"[tabular/chat] failed to save aborted stream",
saveError,
);
}
await db
.from("tabular_review_chats")
.update({ updated_at: new Date().toISOString() })
.eq("id", chatId);
}
return;
}
console.error("[tabular/chat] error", err);