Meeting Insights
Meeting Insights are AI-generated analyses of interview meeting transcripts. When a meeting on book.aiqlick.com has a transcript (produced by the Transcription System), an insight can be generated either manually by the user or automatically when the meeting ends. The LLM produces a structured report covering skills assessment, communication analysis, key topics, and red flags.
How It Works
There are two trigger paths for generating an insight: manual (user-initiated) and automatic (webhook-triggered when a meeting ends).
Manual Trigger
- The user clicks Generate Insight in the frontend meeting detail view.
- The frontend calls the backend
initializeMeetingInsightGraphQL mutation, which creates aMeetingInsightrecord with statusGENERATINGand increments the version number. Previous versions are markedSTALE. - The frontend connects to background-tasks via
graphql-wsand subscribes to thegenerateMeetingInsightsubscription. - Background-tasks streams real-time progress updates (
MeetingInsightProcessing) with percentage and status messages as the LLM processes the transcript. - On success, it emits
MeetingInsightSuccesswith the full insight data. The frontend then refetches the latest insight from the backend. - On failure, it emits
MeetingInsightFailurewith an error code and message.
Auto Trigger
- When a Jitsi meeting ends, Prosody fires the
muc-room-destroyedevent. - The
mod_event_sync_componentLua plugin sends a POST request tohttps://ai.aiqlick.com/api/v1/jitsi/events/room/destroyedwith a 2-second delay (to allow occupant-left events to fire first). - The background-tasks handler validates the request, skips breakout rooms, and checks that auto-generation is enabled (
AUTO_GENERATE_MEETING_INSIGHTSenv var, default:true). - A background task runs
_trigger_insight_generation(), which:- Verifies the transcript exists and exceeds 100 characters
- Checks no insight is already
GENERATINGorCOMPLETED(idempotency) - Looks up the
interviewIdviaMeeting→InterviewBooking→Interview - Updates
Meeting.statustoCOMPLETED - Calls
MeetingInsightService.generate_insight()
Prosody Event Sync Plugin
The mod_event_sync_component.lua plugin is the bridge between Jitsi's XMPP layer and the background-tasks webhook API. It runs on Prosody's MUC component (muc.meet.jitsi).
Configuration
The plugin is loaded via the XMPP_MUC_MODULES environment variable in docker-compose.yml:
XMPP_MUC_MODULES=event_sync_component
The plugin file lives in prosody-plugins-custom/ and is mounted into the Prosody container via a Docker Compose volume.
| Config Option | Default | Description |
|---|---|---|
event_sync_webhook_url | https://ai.aiqlick.com/api/v1/jitsi/events | Base URL for webhook POSTs |
event_sync_webhook_secret | (empty) | Secret sent as X-Webhook-Secret header |
Hooks
The plugin registers four Prosody hooks:
| Hook | Webhook Path | Payload |
|---|---|---|
muc-room-created | /room/created | room_name, room_jid, created_at |
muc-room-destroyed | /room/destroyed | room_name, room_jid, occupant_count, occupants, destroyed_at |
muc-occupant-joined | /occupant/joined | room_name, occupant_jid, nick, joined_at |
muc-occupant-left | /occupant/left | room_name, occupant_jid, nick, left_at |
Behavior Details
- Hidden domain filtering: Occupants with JIDs containing
hidden.meet.jitsi(transcriber bots like Jigasi) are excluded from join/leave events. - Delayed room-destroyed: The
muc-room-destroyedevent uses a 2-secondtimer.add_taskdelay to ensure all occupant-left events have fired before the webhook is sent. - Room name extraction: The room name is extracted from the room JID by taking the local part before
@(e.g.,myroom@muc.meet.jitsibecomesmyroom).
Meeting ID Correlation
Jigasi connects via WebSocket with a random UUID, but Prosody sends the actual room_name in webhooks. The background-tasks jitsi_events.py module maintains an in-memory correlation map:
- On
room/created, theroom_nameis registered as a pending room with a timestamp. - When Jigasi connects via WebSocket at
/transcription/ws/{meeting_id},correlate_websocket_meeting_id()matches the UUID to the most recent pending room within a 60-second window. - Transcriptions are stored under the
room_name(not the UUID), enabling the insight generator to find them.
Meeting Record Auto-Creation
The MeetingInsight table has a foreign key to Meeting.id. If the interview booking flow did not create a Meeting record (e.g., due to a unique constraint conflict or a transient error), the insight service auto-creates one on demand.
The auto-creation logic in MeetingInsightModel._get_or_create_meeting_uuid() tries two strategies in order:
- From InterviewBooking — Looks up
InterviewBooking.externalBookingId(which stores the Jitsi room name). If found, creates aMeetingrecord with the booking's organizer, company, schedule, and interview link. - From Transcription data — If no booking exists, looks for a user in the
Transcriptiontable for that room. Creates a minimalMeetingwith that user as organizer. If no transcription user is found, auto-creation fails and the insight generation returns an error.
Auto-creation only runs on write operations (create_insight, create_generating_insight). Read operations like get_latest_by_meeting use a pure lookup that never creates records.
Jitsi Moderator Panel Authentication
The "Generate Insights" button in the Jitsi moderator panel requires a valid JWT token. The token is extracted from the Jitsi Redux state (state['features/base/jwt'].jwt) and passed in the WebSocket connection_init payload as Authorization: Bearer <token>.
If no JWT is present (e.g., a guest user joining without authentication), the button shows "Authentication required — Please sign in through AIQLick to generate meeting insights."
Insight Generation Pipeline
The MeetingInsightService in background-tasks orchestrates the generation process through these steps:
| Step | Progress | Description |
|---|---|---|
| Initializing | 5% | Creates a placeholder MeetingInsight record with status GENERATING |
| Fetching transcript | 10% | Retrieves transcript text from the Transcription table by meetingId |
| Fetching context | 20% | Queries job title, description, requirements, and candidate skills/experience |
| Billing pre-check | 20% | Validates subscription and credit balance via BillingGate |
| Generating | 30% | Sends transcript + context to LLM (30-60 seconds) |
| Saving | 90% | Updates MeetingInsight record with structured results |
| Completed | 100% | Deducts credits via BillingGate.check_and_consume() |
LLM Configuration
| Parameter | Value |
|---|---|
| Model | Claude Haiku 4.5 (eu.anthropic.claude-haiku-4-5-20251001-v1:0) |
| Max tokens | 8192 (2x default) |
| Temperature | 0.3 |
| Provider | AWS Bedrock (eu-north-1) |
Haiku 4.5 is chosen over Sonnet for meeting insights because it is approximately 50% faster, and the structured analysis format benefits less from the additional reasoning capability of larger models.
Insight Content
A completed MeetingInsight contains the following structured data:
fullReport
A markdown-formatted comprehensive report of the meeting analysis, including all sections below in narrative form.
skillsAssessment
| Field | Type | Description |
|---|---|---|
matchedSkills | Array | Skills the candidate demonstrated, with skillName and demonstratedLevel |
missingSkills | Array | Required skills the candidate did not demonstrate |
overallSkillMatchPercentage | Number | Overall skill match score (0-100%) |
communicationAnalysis
| Field | Type | Description |
|---|---|---|
clarityScore | Number | How clearly the candidate communicated (0-100) |
confidenceScore | Number | Confidence level displayed (0-100) |
professionalismScore | Number | Professional conduct score (0-100) |
strengths | Array | Communication strengths observed |
areasForImprovement | Array | Areas where communication could improve |
keyTopicsSummary
| Field | Type | Description |
|---|---|---|
topics | Array | Main topics discussed during the meeting |
questions | Array | Key questions asked by the interviewer |
candidateQuestions | Array | Questions asked by the candidate |
redFlagsAndConcerns
| Field | Type | Description |
|---|---|---|
inconsistencies | Array | Inconsistencies found in candidate's responses |
experienceGaps | Array | Gaps in experience relative to job requirements |
behavioralConcerns | Array | Behavioral issues noted during the interview |
overallRiskLevel | String | Risk assessment: LOW, MEDIUM, or HIGH |
Metadata Fields
| Field | Description |
|---|---|
version | Incrementing version number per meeting |
status | GENERATING, COMPLETED, FAILED, STALE |
llmModel | Model ID used for generation |
llmTokensUsed | Total tokens consumed |
processingTimeMs | Wall-clock time for generation |
transcriptLength | Character count of the transcript |
generatedAt | Timestamp of completion |
errorMessage | Error details (when status is FAILED) |
Database Model
The MeetingInsight table stores all generated insights:
| Column | Type | Notes |
|---|---|---|
id | UUID | Primary key |
meetingId | UUID | FK to Meeting |
interviewId | UUID (optional) | FK to Interview |
version | Int | Auto-incrementing per meeting |
status | Enum | GENERATING, COMPLETED, FAILED, STALE |
fullReport | Text | Markdown report |
skillsAssessment | JSON | Structured skills data |
communicationAnalysis | JSON | Communication scores |
keyTopicsSummary | JSON | Topics and questions |
redFlagsAndConcerns | JSON | Risk assessment |
Versioning
Each regeneration creates a new row with an incremented version. When a new insight is initialized, all previous non-STALE versions are marked STALE. The latest non-STALE insight is the active one.
Access Control
Access to insights is controlled via the meeting relationship:
- Meeting organizer always has access
- Meeting attendees have access
- Users belonging to the meeting's company have access
- Only the meeting organizer can delete insights
Frontend Integration
The useMeetingInsights hook (hooks/useMeetingInsights.ts) provides a complete interface for working with meeting insights.
Usage
const {
insight, // Current MeetingInsight or null
generationState, // { status, progress, message }
history, // All versions for this meeting
isGenerating, // Boolean shorthand
hasInsight, // Boolean: completed insight exists
canRegenerate, // Boolean: not currently generating
generateInsight, // (forceRefresh?: boolean) => Promise<void>
refreshInsight, // () => Promise<void>
deleteInsight, // (id: string) => Promise<void>
stopGeneration, // () => void — cancel active subscription
} = useMeetingInsights({
meetingId: 'room-abc123',
interviewId: 'interview-uuid', // optional
autoFetch: true, // fetch latest on mount
});
Generation Flow in the Hook
generateInsight()calls the backendinitializeMeetingInsightmutation to create aGENERATINGrecord.- It then opens a
subscribeAisubscription on the same Apollo client (no separate WS), naming the operationgenerateMeetingInsight. The NestJS AI gateway proxies it to background-tasks. - Subscribes to
generateMeetingInsightwhich returns a union type:MeetingInsightProcessing | MeetingInsightSuccess | MeetingInsightFailure. MeetingInsightProcessingupdates provide real progress percentage and status messages from the LLM pipeline.- On
MeetingInsightSuccess, the hook refetches the latest insight from the backend (to get all relations). - On
MeetingInsightFailure, the error is stored and the subscription is cleaned up. - The WebSocket client is disposed on completion, failure, or component unmount.
GraphQL Operations
| Operation | Type | Purpose |
|---|---|---|
INITIALIZE_MEETING_INSIGHT | Mutation | Creates a GENERATING record, returns insight ID |
GET_MEETING_INSIGHT | Query | Fetches a specific insight by ID (used for polling) |
GET_LATEST_MEETING_INSIGHT | Query | Fetches the latest non-STALE insight for a meeting |
GET_MEETING_INSIGHTS_HISTORY | Query | Fetches all versions for a meeting |
DELETE_MEETING_INSIGHT | Mutation | Deletes an insight (organizer only) |
The frontend subscribes via the NestJS AI gateway (subscribeAi with operation: 'generateMeetingInsight') over the same NEXT_PUBLIC_GRAPHQL_WS_URL WebSocket as the rest of the backend. The gateway proxies the upstream generateMeetingInsight subscription to background-tasks; the frontend never opens a direct connection there. See NestJS AI Gateway.
Environment Variables
| Variable | Service | Default | Description |
|---|---|---|---|
AUTO_GENERATE_MEETING_INSIGHTS | background-tasks | true | Enable auto-generation on meeting end |
JITSI_WEBHOOK_SECRET | background-tasks | (none) | Bearer token to validate Prosody webhook requests |
event_sync_webhook_url | Prosody | https://ai.aiqlick.com/api/v1/jitsi/events | Webhook base URL |
event_sync_webhook_secret | Prosody | (empty) | Secret header for webhook auth |
XMPP_MUC_MODULES | Prosody | — | Must include event_sync_component |
Billing
Meeting insight generation is a billable AI operation. The MeetingInsightService integrates with BillingGate:
- Pre-check: Before the LLM call,
BillingGate.pre_check(company_id, "MEETING_INSIGHT")validates the company has an active subscription and sufficient credits. - Consumption: After successful generation,
BillingGate.check_and_consume()deducts credits based on token usage. - Company resolution: The
company_idis resolved from the meeting's linked interview and job chain:Meeting→InterviewBooking→Interview→Job.companyId.
If no company_id can be resolved (e.g., ad-hoc meetings without an interview link), the billing gate is skipped entirely. The insight is generated without credit deduction.
Troubleshooting
Insight not generated automatically after meeting ends
- Check Prosody plugin is loaded: Verify
XMPP_MUC_MODULESincludesevent_sync_componentin the Prosody container environment. - Check webhook delivery: Look at Prosody logs for
Event sent successfullyorEvent webhook failedmessages.docker compose logs prosody | grep -i "event" - Check background-tasks received the webhook: Look for
Received room destroyed eventin background-tasks logs. - Check transcript length: Auto-generation requires at least 1 character of transcript. If the transcription failed entirely, the insight is skipped.
- Check idempotency: If an insight already exists with status
GENERATINGorCOMPLETED, the auto-trigger skips to avoid duplicates.
Insight stuck in GENERATING status
- The manual trigger creates a
GENERATINGrecord via the backend, then connects to background-tasks via WebSocket. If background-tasks is down, the subscription fails and the record stays inGENERATING. - Check background-tasks health:
curl https://ai.aiqlick.com/health - The frontend will show the subscription error. Refresh the page and try again once background-tasks is healthy.
"Transcript too short" error
The insight generator requires at least 1 character of transcript text. This typically means:
- The transcription service (Jigasi) was not running during the meeting.
- There was a meeting ID correlation failure (see Meeting ID Correlation above).
- JVB failed to forward audio to Jigasi (check Jigasi logs for "Colibri WebSocket URL unavailable").
"Meeting not found" error
This previously blocked insight generation when no Meeting record existed for the Jitsi room name. As of April 2026, the service auto-creates the Meeting record from InterviewBooking data. If this error still occurs, it means:
- No
InterviewBookingexists with a matchingexternalBookingId(room name). - No transcription users were found for the room.
- Check if the interview was booked through a non-standard flow that didn't create a booking record.
Billing pre-check fails
If the company's subscription is inactive or credits are exhausted, the insight generation fails with a billing error. Check the company's subscription status and credit balance in the admin dashboard.
Webhook authentication fails
In production, the JITSI_WEBHOOK_SECRET must be configured on the background-tasks side. If Prosody sends requests without the matching secret, they are rejected with HTTP 401. Verify both sides have the same secret configured.