seqcolyte / studio /src /lib /chat.ts
seqmachines's picture
Deploy Seqcolyte Studio
ff34739 verified
Raw
History Blame Contribute Delete
5.22 kB
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");
}