Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/docs/components.yml
Original file line number Diff line number Diff line change
Expand Up @@ -907,9 +907,9 @@ components:
type: string
url:
type: string
fileName:
type: string
description: On-disk filename; present when the resource is an uploaded PDF file.
hasPdf:
type: boolean
description: True when a PDF file is attached to this resource. The on-disk filename is not exposed.
citation:
type: string
description: Full formatted citation string. Used verbatim in RAG metadata when provided; falls back to title/authors/year otherwise.
Expand Down
12 changes: 7 additions & 5 deletions src/models/conversation.model.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import mongoose, { HydratedDocument, Model } from 'mongoose'
import slugify from 'slugify'

import { toJSON, paginate, lock } from './plugins/index.js'
import { toJSON, paginate, lock, hasPdf } from './plugins/index.js'
import { IConversation, Profile, Resource } from '../types/index.types.js'
import Message from './message.model.js'
import transcriptSchema from './schemas/transcript.schema.js'
Expand All @@ -12,8 +12,8 @@ interface ConversationMethods {

type ConversationModel = Model<IConversation, Record<string, never>, ConversationMethods>

// Resources are embedded rather than standalone: they are never queried outside their
// conversation, have no independent lifecycle, and cascade-delete naturally with the parent.
/* Resources are embedded rather than standalone: they are never queried outside their
conversation, have no independent lifecycle, and cascade-delete naturally with the parent. */
const resourceSchema = new mongoose.Schema<Resource>({
source: { type: String, enum: ['speaker', 'ai'], required: true },
category: { type: String, enum: ['required', 'referenced', 'suggested'], required: true },
Expand All @@ -30,6 +30,9 @@ const resourceSchema = new mongoose.Schema<Resource>({
addedAt: { type: Date, default: Date.now }
})
resourceSchema.plugin(toJSON)
/* hasPdf must run after toJSON so it can read fileName from doc after toJSON
has already stripped it from ret. */
resourceSchema.plugin(hasPdf)

const profileSchema = new mongoose.Schema<Profile>(
{
Expand Down Expand Up @@ -73,8 +76,7 @@ const conversationSchema = new mongoose.Schema<IConversation, ConversationModel>
conversationType: {
type: String,
trim: true,
required: false,
immutable: true
required: false
},
platforms: {
type: [String],
Expand Down
22 changes: 22 additions & 0 deletions src/models/plugins/hasPdf.plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/* eslint-disable no-param-reassign */
/* eslint-disable @typescript-eslint/no-explicit-any */
import mongoose from 'mongoose'

/*
* Adds hasPdf to the serialized output of any resource schema. Apply this
* after the toJSON plugin — by then, fileName is already stripped from ret,
* but it's still readable on doc (the raw Mongoose document), so we derive
* hasPdf there instead of exposing the actual path.
*/
const hasPdfPlugin = (schema: mongoose.Schema) => {
const prev = schema.options.toJSON?.transform
schema.options.toJSON = {
...schema.options.toJSON,
transform(doc: any, ret: any, options: any) {
if (typeof prev === 'function') prev(doc, ret, options)
ret.hasPdf = !!doc.fileName
}
}
}

export default hasPdfPlugin
1 change: 1 addition & 0 deletions src/models/plugins/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { default as toJSON } from './toJSON.plugin.js'
export { default as paginate } from './paginate.plugin.js'
export { default as lock } from './lock.plugin.js'
export { default as hasPdf } from './hasPdf.plugin.js'
236 changes: 231 additions & 5 deletions src/services/conversation.service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ const createConversationFromType = async (params, user) => {
/**
* Update a conversation
* @param {Object} conversationBody
* @param {Object} user
* @returns {Promise<Conversation>}
*/
const updateConversation = async (conversationBody, user) => {
Expand All @@ -266,9 +267,223 @@ const updateConversation = async (conversationBody, user) => {
throw new ApiError(httpStatus.BAD_REQUEST, 'Cannot update an active conversation')
}

const { resources: incomingResources, ...restBody } = conversationBody
const {
resources: incomingResources,
properties: incomingProperties,
features: incomingFeatures,
/* platforms is extracted manually so we can detect changes and recreate adapters.
Leaving it in restBody would let updateDocument overwrite platforms directly,
bypassing the adapter reconciliation below. */
platforms: incomingPlatforms,
topicId,
type,
...restBody
} = conversationBody

const oldResources = incomingResources !== undefined ? [...conversationDoc.resources] : null

/* updateDocument sets doc[key] = body[key] directly, so passing properties through
would overwrite all existing keys with only what the caller sent. We merge manually
so only the changed keys are affected. */
if (incomingProperties !== undefined) {
conversationDoc.properties = { ...conversationDoc.properties, ...incomingProperties }
conversationDoc.markModified('properties') // Mongoose won't detect changes inside a Mixed field without this

/* The Zoom adapter's meetingUrl and botName are set once at creation from Handlebars
templates. They don't sync automatically when the conversation's properties change,
so we update the adapter config here too. */
const adapterConfigUpdates: Record<string, unknown> = {}
if (incomingProperties.zoomMeetingUrl !== undefined) {
adapterConfigUpdates.meetingUrl = incomingProperties.zoomMeetingUrl
}
if (incomingProperties.botName !== undefined) {
adapterConfigUpdates.botName = incomingProperties.botName
}
if (Object.keys(adapterConfigUpdates).length > 0) {
const zoomAdapters = await Adapter.find({ conversation: conversationDoc._id, type: 'zoom' })
for (const adapter of zoomAdapters) {
adapter.config = { ...adapter.config, ...adapterConfigUpdates }
adapter.markModified('config')
await adapter.save()
}
}

/* Agents get their llmModel and llmPlatform baked in at creation time via $ref
resolution. They don't pick up property changes automatically, so push any
model update to Agent documents now. */
if (incomingProperties.llmModel !== undefined) {
/* llmModel is stored as { llmModel: string, llmPlatform: string }, an enum property
validated by the frontend. Check both keys before writing; a malformed payload
would otherwise null out the model on every agent. */
const modelObj = incomingProperties.llmModel as Record<string, string>
const { llmModel, llmPlatform } = modelObj
if (llmModel && llmPlatform) {
await Agent.updateMany({ conversation: conversationDoc._id }, { $set: { llmModel, llmPlatform } })
}
}
}

if (incomingFeatures !== undefined) {
conversationDoc.features = incomingFeatures
conversationDoc.markModified('features') // Mongoose won't detect changes inside a Mixed array without this

/* When features change without a type change, reconcile agents to match. Type changes
recreate all agents from scratch (see the block below), so skip this path when
type is also changing. */
if (type === undefined || type === conversationDoc.conversationType) {
// conversationDoc.conversationType is always set for a persisted conversation
const convType = conversationDoc.conversationType ? getConversationType(conversationDoc.conversationType) : null
if (convType) {
/* A feature is enabled if it's present in the array and its enabled flag is
not explicitly false. */
const enabledFeatureNames = new Set(incomingFeatures.filter((f) => f.enabled !== false).map((f) => f.name))

for (const featureDef of convType.features ?? []) {
for (const agentSpec of featureDef.agents ?? []) {
if (!enabledFeatureNames.has(featureDef.name)) {
/* Feature disabled: remove the agent document and drop the ref from
the conversation's agents array. */
const agentToRemove = await Agent.findOne({
conversation: conversationDoc._id,
agentType: agentSpec.name
})
if (agentToRemove) {
await agentToRemove.deleteOne()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
conversationDoc.agents = (conversationDoc.agents as any[]).filter(
(a) => a.toString() !== agentToRemove._id.toString()
)
}
} else {
/* Feature enabled: create the agent if it doesn't exist yet. */
const exists = await Agent.findOne({ conversation: conversationDoc._id, agentType: agentSpec.name })
if (!exists) {
const resolved = resolveConversationType(
{
platforms: conversationDoc.platforms,
properties: conversationDoc.properties,
features: incomingFeatures
},
convType
)
const agentDef = resolved.agentTypes.find((a) => a.name === agentSpec.name)
if (agentDef) {
const agent = await agentService.createAgent(agentDef.name, conversationDoc, agentDef.properties)
/* Push the ObjectId, not the full document. conversationDoc.agents holds
plain refs when not populated; mixing full docs in causes type errors
later. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
conversationDoc.agents.push(agent._id as any)
}
}
}
}
}
}
}
}

/* When platforms change without a type change, delete the existing adapters and recreate
them for the new combination. Without this, switching from Zoom-only to Zoom+NextSpace
keeps the old adapter config with the wrong dmChannels count. */
if (incomingPlatforms !== undefined && (type === undefined || type === conversationDoc.conversationType)) {
const currentSorted = (conversationDoc.platforms ?? []).slice().sort().join(',')
const newSorted = incomingPlatforms.slice().sort().join(',')
if (currentSorted !== newSorted) {
await Adapter.deleteMany({ conversation: conversationDoc._id })
conversationDoc.adapters = []

// conversationDoc.conversationType is always set for a persisted conversation
const convType = conversationDoc.conversationType ? getConversationType(conversationDoc.conversationType) : null
if (convType) {
const resolved = resolveConversationType(
{
platforms: incomingPlatforms,
properties: conversationDoc.properties,
features: incomingFeatures ?? conversationDoc.features
},
convType
)
for (const adapterProps of resolved.adapters) {
const adapter = await adapterService.createAdapter(adapterProps, conversationDoc)
conversationDoc.adapters.push(adapter)
}
}
}
conversationDoc.platforms = incomingPlatforms
}

if (topicId !== undefined) {
const newTopic = await Topic.findById(topicId)
if (!newTopic) {
throw new ApiError(httpStatus.NOT_FOUND, 'Topic not found')
}

/* Keep topic membership in sync on both sides. Without this, the old topic's
conversations list would still include this event after it's been reassigned. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const oldTopic = conversationDoc.topic as any
if (oldTopic?._id?.toString() !== topicId) {
await Topic.findByIdAndUpdate(oldTopic._id, { $pull: { conversations: conversationDoc._id } })
newTopic.conversations.push(conversationDoc.toObject())
await newTopic.save()
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
conversationDoc.topic = newTopic._id as any
}

// Agents and adapters are wired to a specific conversation type, so switching types requires recreating them.
if (type !== undefined && type !== conversationDoc.conversationType) {
const conversationType = getConversationType(type)
if (!conversationType) {
throw new ApiError(httpStatus.NOT_FOUND, `Conversation type ${type} not found`)
}

/* If a platform list was sent alongside the type change, use it; otherwise keep
the existing platforms. The platform reconciliation block above is skipped when
type is also changing, so this is the one place incomingPlatforms gets applied. */
const effectivePlatforms = incomingPlatforms ?? conversationDoc.platforms

/* Verify the effective platforms are supported by the new type.
resolveConversationType silently produces no adapters for unrecognized platforms,
so we catch this here and return a clear error instead. */
const incompatiblePlatforms = effectivePlatforms?.filter((p) => !conversationType.platforms.some((cp) => cp.name === p))
if (incompatiblePlatforms?.length) {
throw new ApiError(httpStatus.BAD_REQUEST, `Platform(s) not supported by ${type}: ${incompatiblePlatforms.join(', ')}`)
}

await Agent.deleteMany({ conversation: conversationDoc._id })
await Adapter.deleteMany({ conversation: conversationDoc._id })
conversationDoc.agents = []
conversationDoc.adapters = []

/* Fall back to the conversation's existing features if none were sent with this
request. Without this, a type-only update would drop all feature-gated agents. */
const resolved = resolveConversationType(
{
platforms: effectivePlatforms,
properties: conversationDoc.properties,
features: incomingFeatures ?? conversationDoc.features
},
conversationType
)

for (const agentType of resolved.agentTypes) {
const agent = await agentService.createAgent(agentType.name, conversationDoc, agentType.properties)
conversationDoc.agents.push(agent)
}
for (const adapterProps of resolved.adapters) {
const adapter = await adapterService.createAdapter(adapterProps, conversationDoc)
conversationDoc.adapters.push(adapter)
}

conversationDoc.conversationType = type
if (incomingPlatforms !== undefined) {
conversationDoc.platforms = incomingPlatforms
}
}

conversationDoc = updateDocument(restBody, conversationDoc)

if (incomingResources !== undefined) {
Expand All @@ -283,6 +498,16 @@ const updateConversation = async (conversationBody, user) => {

await conversationDoc!.save()

/* Reschedule auto-start and auto-stop jobs whenever the scheduled times change.
scheduleConversationAutoStart cancels the existing job before creating the new one,
so this is safe to call even if a job was already registered. */
if (restBody.scheduledTime !== undefined && conversationDoc!.scheduledTime) {
await scheduleConversationAutoStart(conversationDoc!)
}
if (restBody.scheduledEndTime !== undefined && conversationDoc!.scheduledEndTime) {
await scheduleConversationAutoStop(conversationDoc!)
}

await transcript.loadEventMetadataIntoVectorStore(conversationDoc!)
websocketGateway.broadcastConversationUpdate(conversationDoc)
return conversationDoc
Expand Down Expand Up @@ -323,10 +548,11 @@ const findById = async (id) => {

const findByIdFull = async (id, user) => {
const conversation = await Conversation.findOne({ _id: id })
.select(`${returnFields} resources`)
.select(`${returnFields} resources topic`)
.populate('agents')
.populate('channels')
.populate('adapters')
.populate({ path: 'topic', select: 'name slug description owner' })
.exec()
if (!conversation) {
throw new ApiError(httpStatus.NOT_FOUND, `Conversation with id ${id} not found`)
Expand All @@ -350,10 +576,10 @@ const findByIdFull = async (id, user) => {
}) as any[]
}
const resources = cleanRet.resources?.map((r) => {
// strip internal fileName
// eslint-disable-next-line @typescript-eslint/no-unused-vars
/* Strip internal fileName and expose hasPdf so the client knows a PDF
is attached without seeing the on-disk path. */
const { _id: resourceId, fileName, ...rest } = r as unknown as Record<string, unknown>
return { ...rest, id: (resourceId as { toString(): string }).toString() }
return { ...rest, id: (resourceId as { toString(): string }).toString(), hasPdf: !!fileName }
})
return {
...cleanRet,
Expand Down
10 changes: 8 additions & 2 deletions src/services/resource.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,14 @@ const resourceSummarySchema = z.object({
.describe('Why this matters or how it connects to real-world practice as 1–2 bullet points.')
})

function formatSummary(s: z.infer<typeof resourceSummarySchema>) {
const bullets = (items: string[]) => items.map((b) => `- ${b}`).join('\n')
export function formatSummary(s: z.infer<typeof resourceSummarySchema>) {
/* The LLM occasionally returns a plain string for a single-item list field.
Coerce to an array so .map() doesn't crash on malformed Bedrock tool-call output. */
const toList = (items: unknown): string[] => (Array.isArray(items) ? (items as string[]) : [String(items)])
const bullets = (items: unknown) =>
toList(items)
.map((b) => `- ${b}`)
.join('\n')
const sections = [
`**Main Thesis**\n${bullets(s.mainThesis)}`,
`**Key Findings**\n${bullets(s.keyFindings)}`,
Expand Down
3 changes: 2 additions & 1 deletion src/types/index.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,8 @@ export interface Resource {
authors?: string[]
year?: string
url?: string
fileName?: string // on-disk name; present when resource is a PDF file
fileName?: string // on-disk name; present when resource is a PDF file (private — stripped from API responses)
hasPdf?: boolean // derived from fileName; true when a PDF is attached
citation?: string // full formatted citation
description?: string // creator-provided relevance note
summary?: string // AI-generated; populated async for required readings
Expand Down
Loading
Loading