Skip to content

Commit 9cecaa2

Browse files
fix(chat): design and report chats can actually read your files
Asking a design or report chat about a file got "I don't have access to files" — and the model was right, because nothing about the file was ever sent to it. Two separate cuts in the pipeline, both of which had to be repaired. The first: `use-chat-handler` skipped retrieval outright for design and report scopes. The reasoning was sound as far as it went — those chats already carry the whole design document in their system prompt, so re-retrieving it wastes budget and adds latency. But the document is not the FILES. Anything the researcher attaches lives in file_items and reaches the model only through retrieval, so attaching a file to a design chat did nothing whatsoever. The skip is gone; retrieval is scope-filtered, not workspace-wide, so it returns this design's own material rather than unrelated documents. The second only became visible once retrieval ran. The RPC ANDs `p_source_types` with `p_only_source_ids`, so a single call can search the design's document or files, never both — and the scope filter pinned it to the document. That matters because the chunker deliberately indexes only FILENAMES and descriptions for a design's or report's attachments, embedding their contents separately under source_type='file'. So the chat retrieved a list of filenames and truthfully reported it could not see inside any of them. `retrieve()` now makes a second, file-scoped pass for those two scopes and fuses both result sets through the existing scorer, which recomputes ranks over whatever rows it is handed. The pass is skipped when the caller already named specific files — those ARE the file pass, and a workspace sweep would only dilute them — and when the scope has no document filter to complement. A failing file pass is caught and logged rather than thrown: the document chunks from the first pass are still worth returning. Four tests cover the scope matrix. The two that assert the file pass fail against the previous code and pass against this one.
1 parent b626a8e commit 9cecaa2

3 files changed

Lines changed: 176 additions & 20 deletions

File tree

__tests__/rag/retrieve.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,108 @@ describe("fuseAndScore", () => {
147147
expect(ranked[ranked.length - 1].score).toBe(0)
148148
})
149149
})
150+
151+
/**
152+
* Two-pass retrieval for design/report scopes.
153+
*
154+
* The RPC ANDs source_type with source_id, so one call can search the design's
155+
* own document OR files, never both. Because the chunker indexes only
156+
* FILENAMES for a design/report's attachments and embeds their contents
157+
* separately under source_type='file', a single document-scoped call left the
158+
* chat unable to see inside any uploaded file — which is what produced the
159+
* "I don't have access to that file" answers.
160+
*/
161+
describe("retrieve — file pass for design/report scopes", () => {
162+
const rpcCalls: any[] = []
163+
164+
const loadRetrieve = () => {
165+
jest.resetModules()
166+
rpcCalls.length = 0
167+
jest.doMock("@/lib/rag/embed", () => ({
168+
embedBatch: jest.fn(async () => [[0.1, 0.2, 0.3]])
169+
}))
170+
jest.doMock("@supabase/supabase-js", () => ({
171+
createClient: () => ({
172+
rpc: async (_name: string, args: any) => {
173+
rpcCalls.push(args)
174+
return { data: [], error: null }
175+
}
176+
})
177+
}))
178+
// eslint-disable-next-line @typescript-eslint/no-var-requires
179+
return require("@/lib/rag/retrieve").retrieve
180+
}
181+
182+
beforeAll(() => {
183+
process.env.NEXT_PUBLIC_SUPABASE_URL ??= "http://localhost"
184+
process.env.SUPABASE_SERVICE_ROLE_KEY ??= "test-key"
185+
})
186+
187+
afterEach(() => jest.dontMock("@/lib/rag/embed"))
188+
189+
test("design scope also searches file content", async () => {
190+
const retrieve = loadRetrieve()
191+
await retrieve({
192+
query: "what is in my data file",
193+
workspaceId: "w1",
194+
scope: "design",
195+
scopeId: "d1",
196+
sourceCount: 5
197+
})
198+
199+
expect(rpcCalls).toHaveLength(2)
200+
expect(rpcCalls[0].p_source_types).toEqual(["design"])
201+
expect(rpcCalls[0].p_only_source_ids).toEqual(["d1"])
202+
// The second pass is what lets the chat read an uploaded file at all.
203+
expect(rpcCalls[1].p_source_types).toEqual(["file", "project_file"])
204+
expect(rpcCalls[1].p_only_source_ids).toBeNull()
205+
expect(rpcCalls[1].p_workspace_id).toBe("w1")
206+
})
207+
208+
test("report scope also searches file content", async () => {
209+
const retrieve = loadRetrieve()
210+
await retrieve({
211+
query: "summarise the results",
212+
workspaceId: "w1",
213+
scope: "report",
214+
scopeId: "r1",
215+
sourceCount: 5
216+
})
217+
218+
expect(rpcCalls).toHaveLength(2)
219+
expect(rpcCalls[0].p_source_types).toEqual(["report"])
220+
expect(rpcCalls[1].p_source_types).toEqual(["file", "project_file"])
221+
})
222+
223+
test("explicitly attached files are the only pass", async () => {
224+
const retrieve = loadRetrieve()
225+
await retrieve({
226+
query: "explain this",
227+
workspaceId: "w1",
228+
scope: "design",
229+
scopeId: "d1",
230+
sourceCount: 5,
231+
fileIds: ["f1", "f2"]
232+
})
233+
234+
// The caller named the files; a workspace-wide file sweep would only
235+
// dilute them.
236+
expect(rpcCalls).toHaveLength(1)
237+
expect(rpcCalls[0].p_source_types).toEqual(["file"])
238+
expect(rpcCalls[0].p_only_source_ids).toEqual(["f1", "f2"])
239+
})
240+
241+
test("workspace scope is unchanged — a single pass", async () => {
242+
const retrieve = loadRetrieve()
243+
await retrieve({
244+
query: "anything",
245+
workspaceId: "w1",
246+
scope: null,
247+
scopeId: null,
248+
sourceCount: 5
249+
})
250+
251+
expect(rpcCalls).toHaveLength(1)
252+
expect(rpcCalls[0].p_source_types).toBeNull()
253+
})
254+
})

components/chat/chat-hooks/use-chat-handler.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -239,19 +239,25 @@ export const useChatHandler = () => {
239239
// (PR-8 wires the >150k-token gate).
240240
let retrievedFileItems: any[] = []
241241

242-
// Design/report chats are "tier-3": the entire document is already in the
243-
// system prompt (ScopedChatRail dumps it into chat.prompt). Running
244-
// workspace RAG for them just adds latency + the misleading "Searching
245-
// files…" indicator and can inject irrelevant chunks. Skip it for those
246-
// scopes; project/workspace chats still retrieve.
242+
// Design/report chats are "tier-3": the DESIGN DOCUMENT is already in the
243+
// system prompt (ScopedChatRail dumps it into chat.prompt), so scope RAG
244+
// used to be skipped for them to save latency and avoid duplicate chunks.
245+
//
246+
// But the document is not the FILES. A paper, an uploaded data file, or
247+
// anything the researcher attaches to the message lives in file_items and
248+
// reaches the model ONLY through retrieval. Skipping unconditionally meant
249+
// attaching a file to a design chat did nothing whatsoever, and the model
250+
// - correctly, given what it was sent - replied that it had no access to
251+
// files. Retrieval is scope-filtered (scope + scope_id), not
252+
// workspace-wide, so running it here returns this design's or report's own
253+
// documents rather than unrelated ones.
247254
const chatScope = (currentChat?.scope ?? null) as
248255
| "project"
249256
| "design"
250257
| "report"
251258
| null
252-
const skipRetrieval = chatScope === "design" || chatScope === "report"
253259

254-
if (useRetrieval && selectedWorkspace && !skipRetrieval) {
260+
if (useRetrieval && selectedWorkspace) {
255261
setToolInUse("retrieval")
256262

257263
retrievedFileItems = await handleRetrieval(

lib/rag/retrieve.ts

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -198,21 +198,66 @@ export async function retrieve(q: RetrieveQuery): Promise<RagItem[]> {
198198
if (!embedding) return []
199199

200200
const supabase = getSupabaseAdmin()
201-
const { data, error } = await supabase.rpc("match_rag_items" as any, {
202-
query_embedding: embedding as any,
203-
query_text: q.query,
204-
match_count: sourceCount * RPC_MATCH_COUNT_MULTIPLIER,
205-
...filters
206-
})
207-
208-
if (error) {
209-
console.error("[rag/retrieve] match_rag_items failed:", error)
210-
throw error
201+
const matchCount = sourceCount * RPC_MATCH_COUNT_MULTIPLIER
202+
203+
const runRpc = async (f: RpcFilters) => {
204+
const { data, error } = await supabase.rpc("match_rag_items" as any, {
205+
query_embedding: embedding as any,
206+
query_text: q.query,
207+
match_count: matchCount,
208+
...f
209+
})
210+
if (error) {
211+
console.error("[rag/retrieve] match_rag_items failed:", error)
212+
throw error
213+
}
214+
return (data ?? []) as unknown as MatchRagItemRow[]
215+
}
216+
217+
const rows = await runRpc(filters)
218+
219+
/**
220+
* Second pass over FILE content for design/report scopes.
221+
*
222+
* The RPC ANDs `p_source_types` with `p_only_source_ids`, so a single call
223+
* can search "this design's document" or "files", never both. Scoping to the
224+
* document alone meant a design or report chat could never see what was
225+
* inside an uploaded file: the chunker deliberately indexes only filenames
226+
* and descriptions for attachments and embeds their contents separately
227+
* under source_type='file'. Asking "what's in my data file?" therefore
228+
* retrieved a list of filenames, and the model answered - accurately for
229+
* what it had been given - that it had no access to the file.
230+
*
231+
* Skipped when the caller already restricted to specific files (those ARE
232+
* the file pass), and when the scope has no document filter to complement.
233+
*/
234+
const needsFilePass =
235+
(q.scope === "design" || q.scope === "report") &&
236+
!(q.fileIds && q.fileIds.length > 0)
237+
238+
let allRows = rows
239+
if (needsFilePass) {
240+
try {
241+
const fileRows = await runRpc({
242+
p_workspace_id: q.workspaceId,
243+
p_project_id: null,
244+
p_source_types: ["file", "project_file"],
245+
p_only_source_ids: null,
246+
p_exclude_source_ids: null
247+
})
248+
// De-duplicate defensively; the two filters are disjoint by source_type,
249+
// but a future filter change shouldn't be able to double-count a chunk.
250+
const seen = new Set(rows.map(r => r.id))
251+
allRows = [...rows, ...fileRows.filter(r => !seen.has(r.id))]
252+
} catch (err) {
253+
// A failed file pass must not take down the whole answer - the document
254+
// chunks from the first pass are still worth returning.
255+
console.warn("[rag/retrieve] file pass failed, continuing:", err)
256+
}
211257
}
212258

213-
const rows = (data ?? []) as unknown as MatchRagItemRow[]
214-
if (rows.length === 0) return []
259+
if (allRows.length === 0) return []
215260

216261
const fileScope = !!(q.fileIds && q.fileIds.length > 0)
217-
return fuseAndScore(rows, sourceCount, { fileScope })
262+
return fuseAndScore(allRows, sourceCount, { fileScope })
218263
}

0 commit comments

Comments
 (0)