Files
ozan a81a450e7e feat: monorepo consolidation — merge CLI, bot, admin, team-tool, website, docs, runner, proxy
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.
2026-05-22 04:55:50 +00:00

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);
}