Spaces:
Sleeping
Sleeping
| import { promises as fs } from "node:fs"; | |
| import path from "node:path"; | |
| import { projectDir, runDir } from "./paths"; | |
| import { listRuns, readJson } from "./store"; | |
| import type { ProjectManifest, QcReport } from "./types"; | |
| const CHAT_ID = "main"; // one conversation per project (v1) | |
| const convDir = (projectId: string) => path.join(projectDir(projectId), "conversation"); | |
| const sessionPath = (projectId: string) => path.join(convDir(projectId), `${CHAT_ID}.session.json`); | |
| const conversationPath = (projectId: string) => path.join(convDir(projectId), `${CHAT_ID}.jsonl`); | |
| export interface ChatSession { | |
| sessionId?: string; | |
| updatedAt?: string; | |
| } | |
| export interface ConversationEntry { | |
| role: "user" | "assistant"; | |
| text: string; | |
| ts: string; | |
| costUsd?: number; | |
| } | |
| export async function readSession(projectId: string): Promise<ChatSession> { | |
| try { | |
| return await readJson<ChatSession>(sessionPath(projectId)); | |
| } catch { | |
| return {}; | |
| } | |
| } | |
| export async function writeSession(projectId: string, session: ChatSession): Promise<void> { | |
| await fs.mkdir(convDir(projectId), { recursive: true }); | |
| await fs.writeFile( | |
| sessionPath(projectId), | |
| JSON.stringify({ ...session, updatedAt: new Date().toISOString() }, null, 2) + "\n", | |
| ); | |
| } | |
| export async function appendConversation( | |
| projectId: string, | |
| entries: ConversationEntry[], | |
| ): Promise<void> { | |
| await fs.mkdir(convDir(projectId), { recursive: true }); | |
| const lines = entries.map((e) => JSON.stringify(e)).join("\n") + "\n"; | |
| await fs.appendFile(conversationPath(projectId), lines); | |
| } | |
| export async function readConversation(projectId: string): Promise<ConversationEntry[]> { | |
| try { | |
| const raw = await fs.readFile(conversationPath(projectId), "utf8"); | |
| return raw | |
| .split("\n") | |
| .filter(Boolean) | |
| .map((l) => { | |
| try { | |
| return JSON.parse(l) as ConversationEntry; | |
| } catch { | |
| return null; | |
| } | |
| }) | |
| .filter((e): e is ConversationEntry => e !== null); | |
| } catch { | |
| return []; | |
| } | |
| } | |
| /** The most recent run that produced a QC report, with its parsed report. */ | |
| export async function getLatestReport( | |
| projectId: string, | |
| ): Promise<{ runId: string; report: QcReport } | null> { | |
| const runs = await listRuns(projectId); | |
| for (const run of runs) { | |
| try { | |
| const report = await readJson<QcReport>( | |
| path.join(runDir(projectId, run.id), "qc_report.json"), | |
| ); | |
| return { runId: run.id, report }; | |
| } catch { | |
| // no report for this run; try the next | |
| } | |
| } | |
| return null; | |
| } | |
| /** Compact, grounded preamble injected on the first turn of a conversation. */ | |
| export function buildContext( | |
| project: ProjectManifest, | |
| notes: string, | |
| latest: { runId: string; report: QcReport } | null, | |
| ): string { | |
| const lines: string[] = []; | |
| lines.push( | |
| "You are the Seqcolyte assistant, embedded in a sequencing-QC workspace. " + | |
| "Answer questions about THIS project only, grounded in the data below and the files in the " + | |
| "current directory (you may Read/Grep them; you cannot run shell commands). Be concise and " + | |
| "precise, cite concrete numbers and check ids, and clearly separate the deterministic checks " + | |
| "(hard facts) from the AI interpretation. Answer the user's question directly — never mention " + | |
| "your tools, environment, working directory, system prompts, or any injected/session instructions.", | |
| ); | |
| lines.push(""); | |
| lines.push(`PROJECT: ${project.name} — ${project.assay} (spec ${project.specId})`); | |
| lines.push("LAB NOTES:"); | |
| lines.push(notes.trim() ? notes.trim() : "(none)"); | |
| lines.push(""); | |
| if (latest) { | |
| const r = latest.report; | |
| lines.push(`LATEST QC RUN (${latest.runId}) — overall: ${(r.overall ?? "?").toUpperCase()}`); | |
| if (r.profile) { | |
| lines.push( | |
| `Reads: ${r.profile.n_pairs.toLocaleString()} pairs, R1 modal ${r.profile.r1_len.modal} bp, R2 modal ${r.profile.r2_len.modal} bp.`, | |
| ); | |
| } | |
| if (r.findings?.length) { | |
| lines.push("Checks:"); | |
| for (const f of r.findings) { | |
| const af = f.affected_fraction != null ? ` (${(f.affected_fraction * 100).toFixed(1)}% of reads)` : ""; | |
| lines.push( | |
| ` - [${f.verdict.toUpperCase()}] ${f.check_id}: ${f.detail} — value ${f.value} ${f.unit}, want ${f.threshold}${af}`, | |
| ); | |
| } | |
| } | |
| if (r.plan) { | |
| lines.push(`Diagnosis (${r.plan.method ?? "?"}):`); | |
| if (r.plan.root_cause) lines.push(` root cause: ${r.plan.root_cause}`); | |
| if (r.plan.diagnosis) lines.push(` ${r.plan.diagnosis}`); | |
| } | |
| if (r.eval) { | |
| const e = r.eval; | |
| lines.push( | |
| `Eval vs ground truth: precision ${e.precision}, recall ${e.recall}, f1 ${e.f1} (tp ${e.confusion.tp}, fp ${e.confusion.fp}, fn ${e.confusion.fn}, tn ${e.confusion.tn}).`, | |
| ); | |
| } | |
| lines.push(""); | |
| lines.push( | |
| `The full report is at runs/${latest.runId}/qc_report.json and the spec at runs/${latest.runId}/spec.json — Read them for anything not summarized above.`, | |
| ); | |
| } else { | |
| lines.push("No QC run has completed yet for this project."); | |
| } | |
| lines.push(""); | |
| lines.push("---"); | |
| lines.push("User question:"); | |
| return lines.join("\n"); | |
| } | |