Skip to content
Open
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
104 changes: 86 additions & 18 deletions apps/backend/shared/ai/schemas/CcStructuredContent.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AiContext } from '@tailor-cms/interfaces/ai.ts';
import type { Activity } from '@tailor-cms/interfaces/activity.ts';
import {
getSchema as getMetaInputSchema,
} from '@tailor-cms/meta-element-collection/schema.js';
Expand Down Expand Up @@ -36,6 +37,7 @@ type SubcontainerConfigs = Record<string, SubcontainerConfig>;

interface ParsedConfig {
subcontainers: SubcontainerConfigs;
defaultSubcontainers: Pick<Activity, 'type' | 'data'>[];
ai?: {
definition?: string;
outputRules?: { prompt?: string };
Expand Down Expand Up @@ -97,21 +99,32 @@ const toMetaFields = (meta: any[]): MetaField[] =>
}),
}));

// Container-level options – not subcontainer type definitions.
const CONTAINER_OPTIONS = [
'isCollapsible',
'collapsedPreviewKey',
'defaultSubcontainers',
];

const EMPTY_CONFIG: ParsedConfig = {
subcontainers: {},
defaultSubcontainers: [],
};

// Parse container schema config into per-subcontainer configs.
// Resolves element types and meta field schemas for each
// subcontainer type defined in the container config.
const getConfigs = (context: AiContext): ParsedConfig => {
const empty: ParsedConfig = { subcontainers: {} };
const { outlineActivityType, containerType } =
context.repository;
if (!outlineActivityType || !containerType) return empty;
if (!outlineActivityType || !containerType) return EMPTY_CONFIG;
const containers = schemaAPI.getSupportedContainers(
outlineActivityType,
);
const container = containers.find(
(c: any) => c.type === containerType,
);
if (!container?.config) return empty;
if (!container?.config) return EMPTY_CONFIG;
// Container-level element types as default fallback
const defaultElementTypes = getElementTypeIds(
container.contentElementConfig,
Expand All @@ -120,6 +133,7 @@ const getConfigs = (context: AiContext): ParsedConfig => {
for (const [type, val] of Object.entries(
container.config as Record<string, any>,
)) {
if (CONTAINER_OPTIONS.includes(type)) continue;
// Subcontainer config overrides container-level
const elementTypes = val.contentElementConfig
? getElementTypeIds(val.contentElementConfig)
Expand All @@ -130,14 +144,22 @@ const getConfigs = (context: AiContext): ParsedConfig => {
metaInputs: toMetaFields(getMetaDefinitions(val)),
};
}
return { subcontainers, ai: container.ai };
return {
subcontainers,
defaultSubcontainers:
container.config.defaultSubcontainers || [],
ai: container.ai,
};
};

// Build JSON schema for a single subcontainer type:
// discriminated by type enum, with per-type elements and data.
// When defaultData is provided, matching meta fields are
// pinned to exact values via JSON Schema `const`.
const buildSubcontainerSchema = (
type: string,
config: SubcontainerConfig,
defaultData?: Record<string, any>,
) => {
const { metaInputs, elementTypes } = config;
const props: Record<string, any> = {
Expand All @@ -149,10 +171,14 @@ const buildSubcontainerSchema = (
const dataRequired: string[] = [];
for (const field of metaInputs) {
if (!field.schema) continue;
const values = field.options?.map((o) => o.value);
dataProps[field.key] = values?.length
? { ...field.schema, enum: values }
: field.schema;
if (defaultData?.[field.key] != null) {
dataProps[field.key] = { ...field.schema, enum: [defaultData[field.key]] };
} else {
const values = field.options?.map((o) => o.value);
dataProps[field.key] = values?.length
? { ...field.schema, enum: values }
: field.schema;
}
dataRequired.push(field.key);
}
if (Object.keys(dataProps).length) {
Expand All @@ -165,27 +191,45 @@ const buildSubcontainerSchema = (
// Build OpenAI structured output schema from container config.
// Each subcontainer type becomes a discriminated union variant
// with its own allowed element types and metadata fields.
// When defaultSubcontainers are defined, uses a tuple to
// enforce exact count, types, and data at each position.
export const Schema = (context: AiContext): OpenAISchema => {
const { subcontainers } = getConfigs(context);
const {
subcontainers, defaultSubcontainers,
} = getConfigs(context);
// Default to generic section when no config is defined
const entries = Object.entries(subcontainers);
if (!entries.length) {
entries.push(['SECTION', {
label: 'Section', metaInputs: [], elementTypes: [],
}]);
}
// Build per-subcontainer schema with type discriminator
const schemas = entries.map(([type, config]) =>
buildSubcontainerSchema(type, config),
);
const subcontainerSchema = schemas.length === 1
? schemas[0]
: { anyOf: schemas };
let subcontainersSchema: Record<string, any>;
if (defaultSubcontainers.length) {
const schemas = entries.map(([type, config]) =>
buildSubcontainerSchema(type, config));
const itemSchema = schemas.length === 1
? schemas[0]
: { anyOf: schemas };
subcontainersSchema = {
type: 'array',
items: itemSchema,
minItems: defaultSubcontainers.length,
maxItems: defaultSubcontainers.length,
};
} else {
const schemas = entries.map(([type, config]) =>
buildSubcontainerSchema(type, config));
const itemSchema = schemas.length === 1
? schemas[0]
: { anyOf: schemas };
subcontainersSchema = { type: 'array', items: itemSchema };
}
return {
type: 'json_schema',
name: 'cc_structured_content',
schema: obj(
{ subcontainers: { type: 'array', items: subcontainerSchema } },
{ subcontainers: subcontainersSchema },
['subcontainers'],
),
};
Expand Down Expand Up @@ -221,10 +265,25 @@ const describeSubcontainerTypes = (configs: SubcontainerConfigs): string => {
.join('\n');
};

const describeDefaultSubcontainers = (
defaults: Pick<Activity, 'type' | 'data'>[],
configs: SubcontainerConfigs,
): string =>
defaults
.map((defaultSub) => {
const label =
configs[defaultSub.type]?.label || defaultSub.type;
const title = defaultSub.data?.title;
return title
? ` - "${title}" (${label})`
: ` - ${label}`;
})
.join('\n');

// Build prompt with available element types, subcontainer types,
// metadata fields, and container-level AI instructions.
export const getPrompt = (context: AiContext) => {
const { subcontainers, ai } = getConfigs(context);
const { subcontainers, defaultSubcontainers, ai } = getConfigs(context);
// Collect all unique element types across subcontainers
const allElementTypes = [
...new Set(Object.values(subcontainers).flatMap((c) => c.elementTypes)),
Expand All @@ -248,6 +307,15 @@ export const getPrompt = (context: AiContext) => {
'- Do not invent information not present in the documents',
);
}
if (defaultSubcontainers.length) {
guidelines.push(
'- Generate exactly these subcontainers in this exact order,'
+ ' one per entry, each with a unique title as listed:\n'
+ describeDefaultSubcontainers(
defaultSubcontainers, subcontainers,
),
);
}
if (ai?.outputRules?.prompt) {
guidelines.push(ai.outputRules.prompt.trim());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,11 @@ const requestDeletion = (

const requestContainerDeletion = (
container: Activity,
name: string = 'container',
opts: string | { force?: boolean } = 'container',
) => {
const action = (val: Activity) => activityStore.remove(val.id);
if (typeof opts === 'object' && opts.force) return action(container);
const name = typeof opts === 'string' ? opts : 'container';
requestDeletion(container, action, name);
};

Expand Down
7 changes: 7 additions & 0 deletions config/src/schemas/partner-training-v2.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,13 @@ const SectionContainer: ContentContainerConfig = {
required: true,
displayHeading: false,
config: {
isCollapsible: true,
defaultSubcontainers: [
{ type: ActivityType.Section, data: { title: 'Intro' } },
{ type: ActivityType.Section, data: { title: 'Key Concepts' } },
{ type: ActivityType.Section, data: { title: 'Top Advisors' } },
{ type: ActivityType.Section, data: { title: 'Talking Points' } },
],
[ActivityType.Section]: {
label: 'Section',
meta: () => [...sectionMeta],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,52 +1,86 @@
<template>
<VCard class="mb-8 py-2 px-5" elevation="4">
<div class="d-flex align-center mt-3 mx-1 mb-8">
<VCardTitle class="text-primary-darken-3 text-truncate">
<VIcon class="mr-2" color="primary-darken-4">
{{ icon }}
</VIcon>
<div
:class="{
'subcontainer-header-collapsible': isCollapsible,
'mb-8': isExpanded,
}"
class="d-flex align-center mt-3 mx-1"
@click="isCollapsible && toggleExpanded()"
>
<VCardTitle class="pb-4 text-primary-darken-3 text-truncate">
<VIcon class="mr-2" color="primary-darken-4">{{ icon }}</VIcon>
{{ label }}
<span
v-if="!isExpanded && collapsedPreviewText"
class="ml-3 text-body-2 text-medium-emphasis font-weight-regular"
>
{{ collapsedPreviewText }}
</span>
</VCardTitle>
<VSpacer />
<VChip
v-if="!isExpanded && elementCount"
class="mr-2 flex-shrink-0"
color="primary-lighten-1"
size="x-small"
variant="tonal"
>
{{ elementCount }} {{ elementCount === 1 ? 'element' : 'elements' }}
</VChip>
<VBtn
v-if="isCollapsible"
:icon="isExpanded ? 'mdi-chevron-up' : 'mdi-chevron-down'"
class="mr-2"
color="primary-darken-3"
size="small"
variant="text"
@click.stop="toggleExpanded"
/>
<VBtn
v-if="isExpanded && !isDisabled"
class="mr-5"
color="secondary-darken-3"
size="small"
variant="tonal"
@click="emit('delete:subcontainer', container, label)"
@click.stop="emit('delete:subcontainer', container, label)"
>
Delete {{ label }}
</VBtn>
</div>
<VRow v-if="processedMeta.length">
<VCol
v-for="input in processedMeta"
:key="input.key"
:cols="input.cols || 12"
class="pt-0 pb-3 px-8"
>
<MetaInput
:meta="input"
@update="(key, val) => (containerData[key] = val)"
<VExpandTransition>
<div v-show="isExpanded">
<VRow v-if="processedMeta.length">
<VCol
v-for="input in processedMeta"
:key="input.key"
:cols="input.cols || 12"
class="pt-0 pb-3 px-8"
>
<MetaInput
:meta="input"
@update="(key, val) => (containerData[key] = val)"
/>
</VCol>
</VRow>
<StructuredContent
v-if="!disableContentElementList"
v-bind="$attrs"
:activities="activities"
:container="container"
:elements="elements"
:is-disabled="isDisabled"
:label="'content elements'"
:layout="layout"
:supported-element-config="contentElementConfig"
@add:subcontainer="emit('add:subcontainer', $event)"
@update:subcontainer="emit('update:subcontainer', $event)"
@delete:subcontainer="emit('delete:subcontainer', $event)"
@delete:element="(el, force) => emit('delete:element', el, force)"
@reorder:element="emit('reorder:element', $event)"
/>
</VCol>
</VRow>
<StructuredContent
v-if="!disableContentElementList"
v-bind="$attrs"
:activities="activities"
:container="container"
:elements="elements"
:is-disabled="isDisabled"
:label="'content elements'"
:layout="layout"
:supported-element-config="contentElementConfig"
@add:subcontainer="emit('add:subcontainer', $event)"
@update:subcontainer="emit('update:subcontainer', $event)"
@delete:subcontainer="emit('delete:subcontainer', $event)"
@delete:element="(el, force) => emit('delete:element', el, force)"
@reorder:element="emit('reorder:element', $event)"
/>
</div>
</VExpandTransition>
</VCard>
</template>

Expand All @@ -71,6 +105,9 @@ const props = defineProps<{
layout?: boolean;
contentElementConfig?: Array<any>;
disableContentElementList?: boolean;
isCollapsible?: boolean;
expandAll?: boolean;
collapsedPreviewKey?: string | null;
}>();

const emit = defineEmits([
Expand All @@ -81,6 +118,30 @@ const emit = defineEmits([
'delete:element',
]);

const elementCount = computed(() => {
return Object.values(props.elements).filter(
(el) => el.activityId === props.container.id,
).length;
});

const isExpanded = ref(true);

const collapsedPreviewText = computed(() => {
const key = props.collapsedPreviewKey || props.meta?.[0]?.key;
return key ? props.container?.data?.[key] || '' : '';
});

const toggleExpanded = () => {
isExpanded.value = !isExpanded.value;
};

watch(
() => props.expandAll,
(expanded) => {
if (props.isCollapsible) isExpanded.value = !!expanded;
},
);

const containerData = ref({ ...props.container?.data }) as any;

const processedMeta = computed(() =>
Expand All @@ -102,4 +163,9 @@ watch(containerData, save, { deep: true });
.meta-container :deep(.v-messages) {
text-align: left;
}

.subcontainer-header-collapsible {
cursor: pointer;
user-select: none;
}
</style>
Loading
Loading