hmc-rag / frontend /src /lib /export.ts
webmuppet
Refactor: replace Streamlit with React+Vite SPA + FastAPI server
b5c0df4
Raw
History Blame Contribute Delete
6.23 kB
/**
* Browser-side Markdown export utilities.
*
* Generates downloadable `.md` files from conversations and saved responses.
* All file generation and download triggering happens entirely in the browser
* without a server round-trip (Requirement 11.4).
*
* Output format:
* ---
* date: YYYY-MM-DD
* model: claude-3-5-sonnet
* type: conversation | response
* ---
*
* # Question text
*
* Answer body
*
* ## Sources
*
* 1. [Title](url) — domain
* 2. Title — domain
*/
import type { Conversation, QueryResult, SavedResponse } from "../types";
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Generates a Markdown export of a full conversation and triggers a browser
* file download.
*
* The filename follows the pattern: `health-marketing-{YYYYMMDD}-{slug}.md`
* where `slug` is derived from the first user question.
*
* Requirements: 11.1, 11.3
*/
export function exportConversation(conversation: Conversation): void {
const today = formatDate(new Date());
const frontMatter = buildFrontMatter(today, "conversation");
const sections: string[] = [frontMatter];
// Iterate over message pairs: user message followed by assistant message.
const { messages, results } = conversation;
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (msg.role !== "user") continue;
const question = msg.content;
const assistantMsg = messages[i + 1];
const answer =
assistantMsg && assistantMsg.role === "assistant"
? assistantMsg.content
: "";
// results are keyed by the assistant message index (as a string).
const resultKey = String(i + 1);
const result = results[resultKey] ?? null;
sections.push(buildQASection(question, answer, result));
}
const markdown = sections.join("\n\n");
const firstQuestion = messages.find((m) => m.role === "user")?.content ?? "";
const slug = slugify(firstQuestion);
const filename = `health-marketing-${today.replace(/-/g, "")}-${slug}.md`;
triggerDownload(markdown, filename);
}
/**
* Generates a Markdown export of a single Q&A pair and triggers a browser
* file download.
*
* Requirements: 11.2, 11.3
*/
export function exportSingleResponse(
question: string,
answer: string,
result: QueryResult
): void {
const today = formatDate(new Date());
const frontMatter = buildFrontMatter(today, "response");
const qaSection = buildQASection(question, answer, result);
const markdown = `${frontMatter}\n\n${qaSection}`;
const slug = slugify(question);
const filename = `health-marketing-${today.replace(/-/g, "")}-${slug}.md`;
triggerDownload(markdown, filename);
}
/**
* Generates a Markdown export of all saved responses concatenated into a
* single file, separated by `---` dividers, and triggers a browser file
* download.
*
* Requirements: 11.1, 11.3
*/
export function exportAllSavedResponses(saved: SavedResponse[]): void {
const today = formatDate(new Date());
const frontMatter = buildFrontMatter(today, "saved-responses");
const responseSections = saved.map((sr) =>
buildQASection(sr.question, sr.answer, sr.result)
);
const markdown =
frontMatter + "\n\n" + responseSections.join("\n\n---\n\n");
const filename = `health-marketing-${today.replace(/-/g, "")}-saved-responses.md`;
triggerDownload(markdown, filename);
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
* Formats a Date as `YYYY-MM-DD` (local date, not UTC).
*/
function formatDate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
/**
* Builds the YAML front-matter block.
*
* Example:
* ---
* date: 2024-01-15
* model: claude-3-5-sonnet
* type: conversation
* ---
*/
function buildFrontMatter(date: string, type: string): string {
return `---\ndate: ${date}\nmodel: claude-3-5-sonnet\ntype: ${type}\n---`;
}
/**
* Builds a single Q&A section: H1 question, answer body, and Sources section.
*
* Example:
* # What are the advertising rules for chiropractors?
*
* [answer body here]
*
* ## Sources
*
* 1. [Section Title](https://example.com) — domain
* 2. Section Title — domain
*/
function buildQASection(
question: string,
answer: string,
result: QueryResult | null
): string {
const parts: string[] = [];
parts.push(`# ${question}`);
parts.push(answer);
if (result && result.citations.length > 0) {
const sourceLines = result.citations.map((citation) => {
const prefix = `${citation.index}.`;
const titlePart = citation.source_url
? `[${citation.title}](${citation.source_url})`
: citation.title;
return `${prefix} ${titlePart}${citation.domain}`;
});
parts.push(`## Sources\n\n${sourceLines.join("\n")}`);
}
return parts.join("\n\n");
}
/**
* Converts a question string into a URL-safe slug for use in filenames.
*
* - Lowercases the string
* - Replaces spaces with hyphens
* - Removes non-alphanumeric characters (except hyphens)
* - Truncates to 40 characters
* - Trims trailing hyphens
*/
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "")
.slice(0, 40)
.replace(/-+$/, "");
}
/**
* Triggers a browser file download using the Blob + URL.createObjectURL
* + anchor click pattern.
*/
function triggerDownload(content: string, filename: string): void {
const blob = new Blob([content], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.style.display = "none";
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
// Release the object URL to free memory.
URL.revokeObjectURL(url);
}