mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-30 21:36:31 +00:00
refactor: move transcripts into core
Move meeting notes into core transcripts, remove the bundled meeting-notes plugin/API, and require explicit transcripts.enabled before exposing the recording-capable tool.
This commit is contained in:
committed by
GitHub
parent
45feb37b13
commit
cac0b2db18
70
src/transcripts/config.ts
Normal file
70
src/transcripts/config.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { normalizeOptionalString as readString } from "../shared/string-coerce.js";
|
||||
|
||||
export type TranscriptsAutoStartConfig = {
|
||||
providerId: string;
|
||||
sessionId?: string;
|
||||
title?: string;
|
||||
accountId?: string;
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
meetingUrl?: string;
|
||||
};
|
||||
|
||||
export type ResolvedTranscriptsAutoStartConfig = {
|
||||
providerId: string;
|
||||
sessionId?: string;
|
||||
title?: string;
|
||||
accountId?: string;
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
meetingUrl?: string;
|
||||
};
|
||||
|
||||
export type TranscriptsConfig = {
|
||||
enabled?: boolean;
|
||||
maxUtterances?: number;
|
||||
autoStart?: TranscriptsAutoStartConfig[];
|
||||
};
|
||||
|
||||
export type ResolvedTranscriptsConfig = {
|
||||
enabled: boolean;
|
||||
maxUtterances: number;
|
||||
autoStart: ResolvedTranscriptsAutoStartConfig[];
|
||||
};
|
||||
|
||||
function resolveAutoStart(raw: unknown): ResolvedTranscriptsAutoStartConfig[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
return raw
|
||||
.map((entry): ResolvedTranscriptsAutoStartConfig | undefined => {
|
||||
const config = entry && typeof entry === "object" ? (entry as Record<string, unknown>) : {};
|
||||
const providerId = readString(config.providerId);
|
||||
if (!providerId) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
providerId,
|
||||
sessionId: readString(config.sessionId),
|
||||
title: readString(config.title),
|
||||
accountId: readString(config.accountId),
|
||||
guildId: readString(config.guildId),
|
||||
channelId: readString(config.channelId),
|
||||
meetingUrl: readString(config.meetingUrl),
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is ResolvedTranscriptsAutoStartConfig => entry !== undefined);
|
||||
}
|
||||
|
||||
export function resolveTranscriptsConfig(raw: unknown): ResolvedTranscriptsConfig {
|
||||
const config = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
|
||||
const maxUtterances =
|
||||
typeof config.maxUtterances === "number" && Number.isFinite(config.maxUtterances)
|
||||
? Math.max(1, Math.min(10_000, Math.floor(config.maxUtterances)))
|
||||
: 2_000;
|
||||
return {
|
||||
enabled: config.enabled === true,
|
||||
maxUtterances,
|
||||
autoStart: resolveAutoStart(config.autoStart),
|
||||
};
|
||||
}
|
||||
33
src/transcripts/manual-source.ts
Normal file
33
src/transcripts/manual-source.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { TranscriptSourceProvider } from "./provider-types.js";
|
||||
|
||||
function parseSpeakerLine(line: string): { speakerLabel?: string; text: string } {
|
||||
const match = /^([^:\n]{1,80}):\s+(.+)$/.exec(line.trim());
|
||||
if (!match) {
|
||||
return { text: line.trim() };
|
||||
}
|
||||
return { speakerLabel: match[1]?.trim(), text: match[2]?.trim() ?? "" };
|
||||
}
|
||||
|
||||
export const manualTranscriptSourceProvider: TranscriptSourceProvider = {
|
||||
id: "manual-transcript",
|
||||
aliases: ["import", "transcript"],
|
||||
name: "Manual Transcript Import",
|
||||
sourceKinds: ["posthoc-transcript"],
|
||||
async importTranscript(request) {
|
||||
const now = new Date().toISOString();
|
||||
return request.text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => parseSpeakerLine(line))
|
||||
.filter((entry) => entry.text)
|
||||
.map((entry, index) => ({
|
||||
id: `${request.session.sessionId}-${index + 1}`,
|
||||
sessionId: request.session.sessionId,
|
||||
startedAt: now,
|
||||
final: true,
|
||||
speaker: {
|
||||
label: entry.speakerLabel ?? request.speakerLabel ?? "Speaker",
|
||||
},
|
||||
text: entry.text,
|
||||
}));
|
||||
},
|
||||
};
|
||||
53
src/transcripts/provider-registry.ts
Normal file
53
src/transcripts/provider-registry.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
resolvePluginCapabilityProvider,
|
||||
resolvePluginCapabilityProviders,
|
||||
} from "../plugins/capability-provider-runtime.js";
|
||||
import {
|
||||
buildCapabilityProviderMaps,
|
||||
normalizeCapabilityProviderId,
|
||||
} from "../plugins/provider-registry-shared.js";
|
||||
import type { TranscriptSourceProvider } from "./provider-types.js";
|
||||
|
||||
export function normalizeTranscriptSourceProviderId(
|
||||
providerId: string | undefined,
|
||||
): string | undefined {
|
||||
return normalizeCapabilityProviderId(providerId);
|
||||
}
|
||||
|
||||
function resolveTranscriptsSourceProviderEntries(cfg?: OpenClawConfig): TranscriptSourceProvider[] {
|
||||
return resolvePluginCapabilityProviders({
|
||||
key: "transcriptSourceProviders",
|
||||
cfg,
|
||||
});
|
||||
}
|
||||
|
||||
function buildProviderMaps(cfg?: OpenClawConfig): {
|
||||
canonical: Map<string, TranscriptSourceProvider>;
|
||||
aliases: Map<string, TranscriptSourceProvider>;
|
||||
} {
|
||||
return buildCapabilityProviderMaps(resolveTranscriptsSourceProviderEntries(cfg));
|
||||
}
|
||||
|
||||
export function listTranscriptSourceProviders(cfg?: OpenClawConfig): TranscriptSourceProvider[] {
|
||||
return [...buildProviderMaps(cfg).canonical.values()];
|
||||
}
|
||||
|
||||
export function getTranscriptSourceProvider(
|
||||
providerId: string | undefined,
|
||||
cfg?: OpenClawConfig,
|
||||
): TranscriptSourceProvider | undefined {
|
||||
const normalized = normalizeTranscriptSourceProviderId(providerId);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
const directProvider = resolvePluginCapabilityProvider({
|
||||
key: "transcriptSourceProviders",
|
||||
providerId: normalized,
|
||||
cfg,
|
||||
});
|
||||
if (directProvider) {
|
||||
return directProvider;
|
||||
}
|
||||
return buildProviderMaps(cfg).aliases.get(normalized);
|
||||
}
|
||||
109
src/transcripts/provider-types.ts
Normal file
109
src/transcripts/provider-types.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
|
||||
export type TranscriptSourceKind =
|
||||
| "live-audio"
|
||||
| "live-caption"
|
||||
| "posthoc-transcript"
|
||||
| "recording-stt";
|
||||
|
||||
export type TranscriptSourceLocator = {
|
||||
providerId: string;
|
||||
kind?: TranscriptSourceKind;
|
||||
accountId?: string;
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
meetingUrl?: string;
|
||||
threadTs?: string;
|
||||
fileId?: string;
|
||||
[key: string]: string | undefined;
|
||||
};
|
||||
|
||||
export type TranscriptParticipant = {
|
||||
id?: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type TranscriptUtterance = {
|
||||
id?: string;
|
||||
sessionId?: string;
|
||||
startedAt?: string;
|
||||
endedAt?: string;
|
||||
speaker?: TranscriptParticipant;
|
||||
text: string;
|
||||
final?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type TranscriptSessionDescriptor = {
|
||||
sessionId: string;
|
||||
title?: string;
|
||||
source: TranscriptSourceLocator;
|
||||
startedAt: string;
|
||||
stoppedAt?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type TranscriptStartRequest = {
|
||||
cfg?: OpenClawConfig;
|
||||
session: TranscriptSessionDescriptor;
|
||||
abortSignal?: AbortSignal;
|
||||
startupWaitMs?: number;
|
||||
onUtterance: (utterance: TranscriptUtterance) => void | Promise<void>;
|
||||
onStatus?: (status: TranscriptSourceStatus) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export type TranscriptsStartResult =
|
||||
| {
|
||||
ok: true;
|
||||
session: TranscriptSessionDescriptor;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type TranscriptStopRequest = {
|
||||
cfg?: OpenClawConfig;
|
||||
sessionId: string;
|
||||
source: TranscriptSourceLocator;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type TranscriptsStopResult =
|
||||
| {
|
||||
ok: true;
|
||||
sessionId: string;
|
||||
stoppedAt?: string;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type TranscriptSourceStatus = {
|
||||
sessionId?: string;
|
||||
active: boolean;
|
||||
message?: string;
|
||||
source?: TranscriptSourceLocator;
|
||||
};
|
||||
|
||||
export type TranscriptImportRequest = {
|
||||
cfg?: OpenClawConfig;
|
||||
session: TranscriptSessionDescriptor;
|
||||
text: string;
|
||||
speakerLabel?: string;
|
||||
};
|
||||
|
||||
export type TranscriptSourceProvider = {
|
||||
id: string;
|
||||
aliases?: readonly string[];
|
||||
name: string;
|
||||
sourceKinds: readonly TranscriptSourceKind[];
|
||||
start?: (request: TranscriptStartRequest) => Promise<TranscriptsStartResult>;
|
||||
stop?: (request: TranscriptStopRequest) => Promise<TranscriptsStopResult>;
|
||||
status?: (
|
||||
source: TranscriptSourceLocator,
|
||||
cfg?: OpenClawConfig,
|
||||
) => Promise<TranscriptSourceStatus[]>;
|
||||
importTranscript?: (request: TranscriptImportRequest) => Promise<TranscriptUtterance[]>;
|
||||
};
|
||||
272
src/transcripts/store.ts
Normal file
272
src/transcripts/store.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import type { Dirent } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import type { TranscriptSessionDescriptor, TranscriptUtterance } from "./provider-types.js";
|
||||
import type { TranscriptsSummary } from "./summary.js";
|
||||
import { renderTranscriptsMarkdown } from "./summary.js";
|
||||
|
||||
export type TranscriptsSessionEntry = {
|
||||
session: TranscriptSessionDescriptor;
|
||||
sessionDir: string;
|
||||
};
|
||||
|
||||
function safeSegment(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "session";
|
||||
}
|
||||
|
||||
function dateSegment(value: string | undefined): string {
|
||||
const isoDate = value?.match(/^(\d{4}-\d{2}-\d{2})T/)?.[1];
|
||||
return isoDate ?? new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function readJsonFile<T>(filePath: string): Promise<T | undefined> {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
|
||||
} catch (err) {
|
||||
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMaxUtterances(value: number | undefined): number | undefined {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.max(1, Math.floor(value));
|
||||
}
|
||||
|
||||
function sameSessionIdentity(
|
||||
left: TranscriptSessionDescriptor,
|
||||
right: TranscriptSessionDescriptor,
|
||||
): boolean {
|
||||
return left.sessionId === right.sessionId && left.startedAt === right.startedAt;
|
||||
}
|
||||
|
||||
export class TranscriptsStore {
|
||||
constructor(private readonly rootDir: string) {}
|
||||
|
||||
sessionDir(session: TranscriptSessionDescriptor): string {
|
||||
return path.join(this.rootDir, dateSegment(session.startedAt), safeSegment(session.sessionId));
|
||||
}
|
||||
|
||||
private async hasSessionMetadata(dir: string): Promise<boolean> {
|
||||
return (await readJsonFile<unknown>(path.join(dir, "metadata.json"))) !== undefined;
|
||||
}
|
||||
|
||||
private async findSessionDirForSession(session: TranscriptSessionDescriptor): Promise<string> {
|
||||
const datedDir = this.sessionDir(session);
|
||||
const datedSession = await readJsonFile<TranscriptSessionDescriptor>(
|
||||
path.join(datedDir, "metadata.json"),
|
||||
);
|
||||
if (datedSession && sameSessionIdentity(datedSession, session)) {
|
||||
return datedDir;
|
||||
}
|
||||
return datedDir;
|
||||
}
|
||||
|
||||
private async findSessionDir(selector: string): Promise<string | undefined> {
|
||||
const qualified = selector.match(/^(\d{4}-\d{2}-\d{2})\/(.+)$/);
|
||||
if (qualified?.[1] && qualified[2]) {
|
||||
const directDir = path.join(this.rootDir, qualified[1], safeSegment(qualified[2]));
|
||||
return (await this.hasSessionMetadata(directDir)) ? directDir : undefined;
|
||||
}
|
||||
|
||||
const safeSessionId = safeSegment(selector);
|
||||
const idDate = selector
|
||||
.match(/^meeting-(\d{4})-(\d{2})-(\d{2})T/)
|
||||
?.slice(1, 4)
|
||||
.join("-");
|
||||
if (idDate) {
|
||||
const directDir = path.join(this.rootDir, idDate, safeSessionId);
|
||||
return (await this.hasSessionMetadata(directDir)) ? directDir : undefined;
|
||||
}
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(this.rootDir, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const datedEntries = entries
|
||||
.filter((entry) => entry.isDirectory() && /^\d{4}-\d{2}-\d{2}$/.test(entry.name))
|
||||
.toSorted((left, right) => right.name.localeCompare(left.name));
|
||||
const matches: string[] = [];
|
||||
for (const entry of datedEntries) {
|
||||
const candidate = path.join(this.rootDir, entry.name, safeSessionId);
|
||||
const session = await readJsonFile<TranscriptSessionDescriptor>(
|
||||
path.join(candidate, "metadata.json"),
|
||||
);
|
||||
if (session?.sessionId === selector) {
|
||||
matches.push(candidate);
|
||||
}
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new Error(
|
||||
`multiple transcripts sessions match ${selector}; use a YYYY-MM-DD/${selector} selector`,
|
||||
);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
async writeSession(session: TranscriptSessionDescriptor): Promise<void> {
|
||||
const dir = this.sessionDir(session);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, "metadata.json"), `${JSON.stringify(session, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async readSession(sessionId: string): Promise<TranscriptSessionDescriptor | undefined> {
|
||||
return (await this.readSessionEntry(sessionId))?.session;
|
||||
}
|
||||
|
||||
async readSessionEntry(sessionId: string): Promise<TranscriptsSessionEntry | undefined> {
|
||||
const dir = await this.findSessionDir(sessionId);
|
||||
if (!dir) {
|
||||
return undefined;
|
||||
}
|
||||
const session = await readJsonFile<TranscriptSessionDescriptor>(
|
||||
path.join(dir, "metadata.json"),
|
||||
);
|
||||
return session ? { session, sessionDir: dir } : undefined;
|
||||
}
|
||||
|
||||
async appendUtterance(sessionId: string, utterance: TranscriptUtterance): Promise<void> {
|
||||
const dir =
|
||||
(await this.findSessionDir(sessionId)) ??
|
||||
path.join(this.rootDir, dateSegment(sessionId), safeSegment(sessionId));
|
||||
await this.appendUtteranceToDir(dir, sessionId, utterance);
|
||||
}
|
||||
|
||||
async appendUtteranceForSession(
|
||||
session: TranscriptSessionDescriptor,
|
||||
utterance: TranscriptUtterance,
|
||||
): Promise<void> {
|
||||
const dir = await this.findSessionDirForSession(session);
|
||||
await this.appendUtteranceToDir(dir, session.sessionId, utterance);
|
||||
}
|
||||
|
||||
private async appendUtteranceToDir(
|
||||
dir: string,
|
||||
sessionId: string,
|
||||
utterance: TranscriptUtterance,
|
||||
): Promise<void> {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.appendFile(
|
||||
path.join(dir, "transcript.jsonl"),
|
||||
`${JSON.stringify({ ...utterance, sessionId })}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
async readUtterancesForSession(
|
||||
session: TranscriptSessionDescriptor,
|
||||
options: { maxUtterances?: number } = {},
|
||||
): Promise<TranscriptUtterance[]> {
|
||||
return await this.readUtterancesFromDir(await this.findSessionDirForSession(session), options);
|
||||
}
|
||||
|
||||
async readUtterancesFromSessionDir(
|
||||
sessionDir: string,
|
||||
options: { maxUtterances?: number } = {},
|
||||
): Promise<TranscriptUtterance[]> {
|
||||
return await this.readUtterancesFromDir(sessionDir, options);
|
||||
}
|
||||
|
||||
async readUtterances(
|
||||
sessionId: string,
|
||||
options: { maxUtterances?: number } = {},
|
||||
): Promise<TranscriptUtterance[]> {
|
||||
const dir = await this.findSessionDir(sessionId);
|
||||
if (!dir) {
|
||||
return [];
|
||||
}
|
||||
return await this.readUtterancesFromDir(dir, options);
|
||||
}
|
||||
|
||||
private async readUtterancesFromDir(
|
||||
dir: string,
|
||||
options: { maxUtterances?: number } = {},
|
||||
): Promise<TranscriptUtterance[]> {
|
||||
const transcriptPath = path.join(dir, "transcript.jsonl");
|
||||
const maxUtterances = normalizeMaxUtterances(options.maxUtterances);
|
||||
if (maxUtterances !== undefined) {
|
||||
const utterances: TranscriptUtterance[] = [];
|
||||
try {
|
||||
const lines = createInterface({
|
||||
input: createReadStream(transcriptPath, { encoding: "utf8" }),
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
for await (const line of lines) {
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
utterances.push(JSON.parse(line) as TranscriptUtterance);
|
||||
if (utterances.length > maxUtterances) {
|
||||
utterances.shift();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return utterances;
|
||||
}
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(transcriptPath, "utf8");
|
||||
} catch (err) {
|
||||
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return raw
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as TranscriptUtterance);
|
||||
}
|
||||
|
||||
async updateStopped(sessionId: string, stoppedAt: string): Promise<void> {
|
||||
const dir = await this.findSessionDir(sessionId);
|
||||
if (!dir) {
|
||||
return;
|
||||
}
|
||||
const session = await readJsonFile<TranscriptSessionDescriptor>(
|
||||
path.join(dir, "metadata.json"),
|
||||
);
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(dir, "metadata.json"),
|
||||
`${JSON.stringify({ ...session, stoppedAt }, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
async writeSummary(
|
||||
summary: TranscriptsSummary,
|
||||
session?: TranscriptSessionDescriptor,
|
||||
): Promise<string> {
|
||||
const dir =
|
||||
session !== undefined
|
||||
? await this.findSessionDirForSession(session)
|
||||
: ((await this.findSessionDir(summary.sessionId)) ??
|
||||
path.join(this.rootDir, dateSegment(summary.sessionId), safeSegment(summary.sessionId)));
|
||||
return await this.writeSummaryToDir(summary, dir);
|
||||
}
|
||||
|
||||
async writeSummaryToDir(summary: TranscriptsSummary, dir: string): Promise<string> {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`);
|
||||
const markdown = renderTranscriptsMarkdown(summary);
|
||||
const markdownPath = path.join(dir, "summary.md");
|
||||
await fs.writeFile(markdownPath, `${markdown}\n`);
|
||||
return markdownPath;
|
||||
}
|
||||
}
|
||||
96
src/transcripts/summary.ts
Normal file
96
src/transcripts/summary.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { normalizeStringEntries } from "../shared/string-normalization.js";
|
||||
import type { TranscriptSessionDescriptor, TranscriptUtterance } from "./provider-types.js";
|
||||
|
||||
export type TranscriptsSummary = {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
generatedAt: string;
|
||||
overview: string;
|
||||
transcript: string[];
|
||||
decisions: string[];
|
||||
actionItems: string[];
|
||||
risks: string[];
|
||||
utteranceCount: number;
|
||||
};
|
||||
|
||||
const ACTION_PATTERNS =
|
||||
/\b(todo|action|follow up|follow-up|assign|owner|next step|ship|fix|send|schedule)\b/i;
|
||||
const DECISION_PATTERNS = /\b(decided|decision|we will|we'll|agreed|approved|go with|ship it)\b/i;
|
||||
const RISK_PATTERNS =
|
||||
/\b(risk|blocked|blocker|concern|issue|problem|unknown|deadline|privacy|security)\b/i;
|
||||
|
||||
function firstSentences(utterances: TranscriptUtterance[], limit: number): string {
|
||||
const text = normalizeStringEntries(utterances.map((utterance) => utterance.text)).join(" ");
|
||||
const sentences = text.match(/[^.!?]+[.!?]?/g) ?? [];
|
||||
return normalizeStringEntries(sentences.slice(0, limit)).join(" ");
|
||||
}
|
||||
|
||||
function collectMatches(utterances: TranscriptUtterance[], pattern: RegExp): string[] {
|
||||
return utterances
|
||||
.filter((utterance) => pattern.test(utterance.text))
|
||||
.map(formatSpeakerLine)
|
||||
.filter(Boolean)
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
function formatSpeakerLine(utterance: TranscriptUtterance): string {
|
||||
const text = utterance.text.trim();
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
const speaker = utterance.speaker?.label?.trim();
|
||||
return speaker ? `${speaker}: ${text}` : text;
|
||||
}
|
||||
|
||||
function formatTranscript(utterances: TranscriptUtterance[]): string[] {
|
||||
return utterances.map(formatSpeakerLine).filter(Boolean);
|
||||
}
|
||||
|
||||
export function summarizeTranscripts(params: {
|
||||
session: TranscriptSessionDescriptor;
|
||||
utterances: TranscriptUtterance[];
|
||||
}): TranscriptsSummary {
|
||||
const title = params.session.title?.trim() || "Transcripts";
|
||||
const overview = firstSentences(params.utterances, 4) || "No transcript captured yet.";
|
||||
return {
|
||||
sessionId: params.session.sessionId,
|
||||
title,
|
||||
generatedAt: new Date().toISOString(),
|
||||
overview,
|
||||
transcript: formatTranscript(params.utterances),
|
||||
decisions: collectMatches(params.utterances, DECISION_PATTERNS),
|
||||
actionItems: collectMatches(params.utterances, ACTION_PATTERNS),
|
||||
risks: collectMatches(params.utterances, RISK_PATTERNS),
|
||||
utteranceCount: params.utterances.length,
|
||||
};
|
||||
}
|
||||
|
||||
function renderList(items: string[]): string {
|
||||
return items.length > 0 ? items.map((item) => `- ${item}`).join("\n") : "- None captured";
|
||||
}
|
||||
|
||||
export function renderTranscriptsMarkdown(summary: TranscriptsSummary): string {
|
||||
return [
|
||||
`# ${summary.title}`,
|
||||
"",
|
||||
`Generated: ${summary.generatedAt}`,
|
||||
`Session: ${summary.sessionId}`,
|
||||
"",
|
||||
"## Overview",
|
||||
summary.overview,
|
||||
"",
|
||||
"## Transcript",
|
||||
renderList(summary.transcript),
|
||||
"",
|
||||
"## Decisions",
|
||||
renderList(summary.decisions),
|
||||
"",
|
||||
"## Action Items",
|
||||
renderList(summary.actionItems),
|
||||
"",
|
||||
"## Risks",
|
||||
renderList(summary.risks),
|
||||
"",
|
||||
`Transcript utterances: ${summary.utteranceCount}`,
|
||||
].join("\n");
|
||||
}
|
||||
Reference in New Issue
Block a user