mirror of
https://github.com/willchen96/mike.git
synced 2026-06-16 21:05:12 +02:00
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:
parent
44e868eb42
commit
f32a194b33
24 changed files with 2494 additions and 1222 deletions
|
|
@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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"> {
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue