a81a450e7e
Merged into tinqs/studio: - cmd/tinqs-cli/ — tinqs-cli (Go binary, from bot/cli) - cmd/tea/ — Gitea CLI tool (from tinqs/cli-tea) - services/bot/ — Bot service (from tinqs-ltd/bot on git.arikigame.com) - services/admin/ — Admin panel (from tinqs/admin) - services/team-tool/ — Team Tool (from tinqs/team-tool) - services/proxy/ — tinqs-proxy (from bot/proxy) - web/landing/ — tinqs.com website (from tinqs/website) - web/docs/ — Platform docs (from tinqs/docs) - web/blog/ — Blog (placeholder) - runner/ — Ephemeral CI runner (from tinqs/runner) All source repos will be deleted after verification.
236 lines
6.7 KiB
TypeScript
236 lines
6.7 KiB
TypeScript
/**
|
|
* Meeting notes generation — constructs prompts from transcripts,
|
|
* calls the configured LLM backend, and returns formatted markdown.
|
|
*/
|
|
|
|
import {
|
|
BedrockRuntimeClient,
|
|
InvokeModelCommand,
|
|
} from '@aws-sdk/client-bedrock-runtime';
|
|
import Anthropic from '@anthropic-ai/sdk';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface TranscriptChunk {
|
|
engine: string;
|
|
final?: string;
|
|
speakers?: string;
|
|
timestamp: number;
|
|
chunk_id: number;
|
|
lang?: string;
|
|
}
|
|
|
|
export interface MeetingNotesRequest {
|
|
sessionId: string;
|
|
category: string;
|
|
startedAt: string;
|
|
stoppedAt: string;
|
|
durationSeconds: number;
|
|
user: string;
|
|
transcripts: TranscriptChunk[];
|
|
template?: string;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Default template (fallback when MEETING_MINUTES_RULES.md is not provided)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const DEFAULT_TEMPLATE = `# Meeting Minutes: {Title}
|
|
|
|
**Date:** {Date}
|
|
**Duration:** {Duration}
|
|
**Category:** {Category}
|
|
**Attendees:** {Attendees}
|
|
|
|
## Purpose
|
|
|
|
{Brief statement of the meeting's purpose or agenda.}
|
|
|
|
## Decisions
|
|
|
|
1. {Decision 1}
|
|
2. {Decision 2}
|
|
|
|
## Action Items
|
|
|
|
| Owner | Task | Due Date |
|
|
|-------|------|----------|
|
|
| {Name} | {Task description} | {Date} |
|
|
|
|
## Lessons Learned
|
|
|
|
- {Key takeaway or insight from the discussion}
|
|
|
|
## Next Steps
|
|
|
|
1. {Next step 1}
|
|
2. {Next step 2}
|
|
|
|
## References
|
|
|
|
- {Links or documents referenced during the meeting}`;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Transcript assembly
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Filters to ElevenLabs finals, sorts by chunk_id, prepends relative
|
|
* timestamps, and returns a single block of text for the LLM.
|
|
*/
|
|
export function buildTranscriptBlock(
|
|
transcripts: TranscriptChunk[],
|
|
sessionStartEpoch: number,
|
|
): string {
|
|
const elevenlabsFinals = transcripts
|
|
.filter((t) => t.engine === 'elevenlabs' && (t.final ?? '').trim().length > 0)
|
|
.sort((a, b) => a.chunk_id - b.chunk_id);
|
|
|
|
if (elevenlabsFinals.length === 0) {
|
|
return '(No ElevenLabs transcripts available.)';
|
|
}
|
|
|
|
return elevenlabsFinals
|
|
.map((t) => {
|
|
const offsetSeconds = Math.max(0, Math.floor(t.timestamp - sessionStartEpoch));
|
|
const minutes = Math.floor(offsetSeconds / 60);
|
|
const seconds = offsetSeconds % 60;
|
|
const ts = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
|
const text = t.speakers?.trim() ? t.speakers.trim() : (t.final ?? '').trim();
|
|
return `[${ts}] ${text}`;
|
|
})
|
|
.join('\n');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Prompt construction
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const SYSTEM_BASE = `You are a meeting notes formatter. Generate structured meeting notes from the provided transcript. Follow the template format EXACTLY. Output only the formatted meeting notes in markdown — no preamble, no explanation.`;
|
|
|
|
export function buildNotesPrompt(req: MeetingNotesRequest): {
|
|
system: string;
|
|
user: string;
|
|
} {
|
|
const template = req.template?.trim() || DEFAULT_TEMPLATE;
|
|
const system = `${SYSTEM_BASE}\n\nFORMAT TEMPLATE:\n${template}`;
|
|
|
|
const sessionStartEpoch = new Date(req.startedAt).getTime() / 1000;
|
|
const transcript = buildTranscriptBlock(req.transcripts, sessionStartEpoch);
|
|
|
|
const hasSpeakerLabels = req.transcripts.some(
|
|
(t) => t.engine === 'elevenlabs' && (t.speakers ?? '').trim().length > 0,
|
|
);
|
|
|
|
const speakerNote = hasSpeakerLabels
|
|
? 'Speaker labels are present in the transcript — map them to attendee names in the Attendees field.'
|
|
: 'Participants not identified — single speaker or no diarization. Use the session user as the sole attendee.';
|
|
|
|
const durationMin = Math.round(req.durationSeconds / 60);
|
|
|
|
const userParts = [
|
|
`Session metadata:`,
|
|
`- Session ID: ${req.sessionId}`,
|
|
`- Category: ${req.category}`,
|
|
`- Date: ${req.startedAt}`,
|
|
`- Duration: ${durationMin} minutes`,
|
|
`- Primary user: ${req.user}`,
|
|
``,
|
|
speakerNote,
|
|
``,
|
|
`Full transcript:`,
|
|
transcript,
|
|
];
|
|
|
|
return { system, user: userParts.join('\n') };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LLM invocation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const LLM_TIMEOUT_MS = 30_000;
|
|
const LLM_MAX_TOKENS = 4096;
|
|
|
|
async function callBedrockNotes(system: string, user: string): Promise<string> {
|
|
const region = process.env.AWS_REGION ?? 'eu-west-1';
|
|
const model = process.env.BEDROCK_MODEL ?? 'mistral.ministral-3-8b-instruct';
|
|
|
|
const client = new BedrockRuntimeClient({
|
|
region,
|
|
requestHandler: { requestTimeout: LLM_TIMEOUT_MS },
|
|
});
|
|
|
|
const body = JSON.stringify({
|
|
messages: [
|
|
{ role: 'system', content: system },
|
|
{ role: 'user', content: user },
|
|
],
|
|
max_tokens: LLM_MAX_TOKENS,
|
|
});
|
|
|
|
const command = new InvokeModelCommand({
|
|
modelId: model,
|
|
contentType: 'application/json',
|
|
accept: 'application/json',
|
|
body: new TextEncoder().encode(body),
|
|
});
|
|
|
|
const response = await client.send(command, {
|
|
abortSignal: AbortSignal.timeout(LLM_TIMEOUT_MS),
|
|
});
|
|
const result = JSON.parse(new TextDecoder().decode(response.body));
|
|
|
|
const text = result?.choices?.[0]?.message?.content;
|
|
if (text) return text.trim();
|
|
|
|
return '(No response from Bedrock model.)';
|
|
}
|
|
|
|
async function callClaudeNotes(system: string, user: string): Promise<string> {
|
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
if (!apiKey) {
|
|
throw new Error('ANTHROPIC_API_KEY is not configured');
|
|
}
|
|
|
|
const model = process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-20250514';
|
|
const client = new Anthropic({ apiKey });
|
|
|
|
const msg = await client.messages.create(
|
|
{
|
|
model,
|
|
max_tokens: LLM_MAX_TOKENS,
|
|
system,
|
|
messages: [{ role: 'user', content: user }],
|
|
},
|
|
{ signal: AbortSignal.timeout(LLM_TIMEOUT_MS) },
|
|
);
|
|
|
|
const block = msg.content.find((b) => b.type === 'text');
|
|
if (block && block.type === 'text') {
|
|
return block.text.trim();
|
|
}
|
|
|
|
return '(No text in Claude response.)';
|
|
}
|
|
|
|
/**
|
|
* Generate meeting notes from a transcript using the configured LLM backend.
|
|
*
|
|
* Set `LLM_PROVIDER=claude` to use Anthropic; defaults to Bedrock (Mistral 8B).
|
|
*/
|
|
export async function generateMeetingNotes(
|
|
req: MeetingNotesRequest,
|
|
): Promise<string> {
|
|
const { system, user } = buildNotesPrompt(req);
|
|
const provider = process.env.LLM_PROVIDER ?? 'bedrock';
|
|
|
|
if (provider === 'claude') {
|
|
return callClaudeNotes(system, user);
|
|
}
|
|
|
|
return callBedrockNotes(system, user);
|
|
}
|