fix(ai): correct RICH_TEXT and MORPH_RELATION record filter operators (#21106)
## Problem
The AI find-records tool generates filter schemas via
`generateFieldFilterZodSchema`. `RICH_TEXT` currently shares the `TEXT`
case, so the agent is told it can use scalar text operators
(`like`/`ilike`/`startsWith`/`endsWith`/`eq`/…) directly on a rich-text
field.
But `RICH_TEXT` is a **composite** type (`markdown` + `blocknote`
sub-fields, see `rich-text.composite-type.ts`). Applying a scalar
operator to the composite root throws at query time:
```
ERROR [FindRecordsService] Failed to find records: Object person doesn't have any "ilike" field.
ERROR [FindRecordsService] Failed to find records: Sub field "ilike" not found for composite type: RICH_TEXT
```
`FindRecordsService` catches and returns `success: false`, so the agent
retries mid-turn — burning latency/tokens — and can **never** search
rich-text body content (note bodies, `about`, etc.).
## Fix
Give `RICH_TEXT` its own case in the filter-schema generator that
exposes the `markdown` and `blocknote` sub-fields, each carrying the
text operators — mirroring the existing composite patterns for `EMAILS`
(`primaryEmail`), `PHONES` (`primaryPhoneNumber`), `LINKS`
(`primaryLinkUrl`), `FULL_NAME`, and `ADDRESS`.
So the agent now emits:
```jsonc
{ "noteBody": { "markdown": { "ilike": "%onboarding%" } } } // valid composite sub-field filter
```
instead of:
```jsonc
{ "noteBody": { "ilike": "%onboarding%" } } // throws on composite root
```
This both **stops the throw** and **makes rich-text content actually
searchable** (the original intent). `TEXT` keeps its existing root-level
scalar operators unchanged.
## Test
Added `__tests__/field-filters.zod-schema.spec.ts`:
- `RICH_TEXT` routes pattern operators onto `markdown` / `blocknote`
- root-level scalar operators on `RICH_TEXT` are no longer accepted
- `TEXT` root-level operators unchanged
## Notes
- No DB/schema migration; render/tool-schema layer only.
- Reproduced against `twentycrm/twenty:latest`; the faulting code is
unchanged on `main` as of this PR.
---------
Co-authored-by: Rich Roberts <rich.roberts@talentpipe.ai>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
This commit is contained in:
+72
@@ -0,0 +1,72 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { generateFieldFilterZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema';
|
||||
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
|
||||
|
||||
const fieldOfType = (
|
||||
type: FieldMetadataType,
|
||||
name = 'body',
|
||||
settings?: Record<string, unknown>,
|
||||
) => ({ type, name, settings }) as FieldMetadataEntity;
|
||||
|
||||
describe('generateFieldFilterZodSchema', () => {
|
||||
describe('TEXT', () => {
|
||||
it('exposes scalar pattern operators at the field root', () => {
|
||||
const schema = generateFieldFilterZodSchema(
|
||||
fieldOfType(FieldMetadataType.TEXT),
|
||||
);
|
||||
|
||||
expect(schema).not.toBeNull();
|
||||
expect(schema!.parse({ ilike: '%foo%' })).toEqual({ ilike: '%foo%' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('RICH_TEXT', () => {
|
||||
// Regression for the AI find-records bug: RICH_TEXT is a composite
|
||||
// (`markdown` / `blocknote` sub-fields). Advertising root-level scalar
|
||||
// operators made the agent emit `{ ilike }`, which the query layer rejects
|
||||
// with `Sub field "ilike" not found for composite type: RICH_TEXT`.
|
||||
it('routes pattern operators onto the markdown sub-field', () => {
|
||||
const schema = generateFieldFilterZodSchema(
|
||||
fieldOfType(FieldMetadataType.RICH_TEXT),
|
||||
);
|
||||
|
||||
expect(schema).not.toBeNull();
|
||||
expect(schema!.parse({ markdown: { ilike: '%hello%' } })).toEqual({
|
||||
markdown: { ilike: '%hello%' },
|
||||
});
|
||||
});
|
||||
|
||||
it('no longer accepts scalar operators at the composite root', () => {
|
||||
const schema = generateFieldFilterZodSchema(
|
||||
fieldOfType(FieldMetadataType.RICH_TEXT),
|
||||
);
|
||||
|
||||
// Root-level operators are stripped (unknown keys), so the malformed
|
||||
// `{ ilike }` filter never reaches the query runner.
|
||||
expect(schema!.parse({ ilike: '%hello%' })).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MORPH_RELATION', () => {
|
||||
// Regression: morph relations (e.g. noteTarget.targetPerson) are filtered
|
||||
// by their join column (`${name}Id`). Without a dedicated case they hit the
|
||||
// text default and advertise like/ilike, which the runner rejects when it
|
||||
// resolves the relation (`Object person doesn't have any "ilike" field`).
|
||||
const uuid = '7def8b6a-ec89-48f1-9835-ec2f7c726ef0';
|
||||
|
||||
it('filters by the related record id and rejects text operators', () => {
|
||||
const schema = generateFieldFilterZodSchema(
|
||||
fieldOfType(FieldMetadataType.MORPH_RELATION, 'targetPerson', {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(schema).not.toBeNull();
|
||||
expect(schema!.parse({ eq: uuid })).toEqual({ eq: uuid });
|
||||
// `ilike` is not a valid operator for a relation id — stripped.
|
||||
expect(schema!.parse({ ilike: '%Tom%' })).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
+13
-5
@@ -3,9 +3,6 @@ import { z } from 'zod';
|
||||
|
||||
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
|
||||
|
||||
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
|
||||
import {
|
||||
AddressFilterSchema,
|
||||
ArrayFieldFilterSchema,
|
||||
@@ -19,9 +16,13 @@ import {
|
||||
NullCheckEnum,
|
||||
NumberFilterSchema,
|
||||
PhonesFilterSchema,
|
||||
RichTextFilterSchema,
|
||||
TextFilterSchema,
|
||||
UuidFilterSchema,
|
||||
} from 'src/engine/core-modules/record-crud/zod-schemas/shared-filter-defs.zod-schema';
|
||||
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
|
||||
|
||||
export { NullCheckEnum };
|
||||
|
||||
@@ -33,9 +34,11 @@ export const generateFieldFilterZodSchema = (
|
||||
return UuidFilterSchema;
|
||||
|
||||
case FieldMetadataType.TEXT:
|
||||
case FieldMetadataType.RICH_TEXT:
|
||||
return TextFilterSchema;
|
||||
|
||||
case FieldMetadataType.RICH_TEXT:
|
||||
return RichTextFilterSchema;
|
||||
|
||||
case FieldMetadataType.NUMBER:
|
||||
case FieldMetadataType.NUMERIC:
|
||||
case FieldMetadataType.POSITION:
|
||||
@@ -130,9 +133,14 @@ export const generateFieldFilterZodSchema = (
|
||||
case FieldMetadataType.LINKS:
|
||||
return LinksFilterSchema;
|
||||
|
||||
case FieldMetadataType.MORPH_RELATION:
|
||||
case FieldMetadataType.RELATION:
|
||||
if (
|
||||
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) &&
|
||||
(isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) ||
|
||||
isFieldMetadataEntityOfType(
|
||||
field,
|
||||
FieldMetadataType.MORPH_RELATION,
|
||||
)) &&
|
||||
field.settings?.relationType === RelationType.MANY_TO_ONE
|
||||
) {
|
||||
return UuidFilterSchema;
|
||||
|
||||
+2
-1
@@ -48,7 +48,8 @@ export const generateRecordFilterSchema = ({
|
||||
}
|
||||
|
||||
const isManyToOneRelationField =
|
||||
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) &&
|
||||
(isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) ||
|
||||
isFieldMetadataEntityOfType(field, FieldMetadataType.MORPH_RELATION)) &&
|
||||
field.settings?.relationType === RelationType.MANY_TO_ONE;
|
||||
|
||||
filterShape[isManyToOneRelationField ? `${field.name}Id` : field.name] =
|
||||
|
||||
+19
@@ -217,6 +217,25 @@ export const PhonesFilterSchema = z
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const RichTextFilterSchema = z
|
||||
.object({
|
||||
markdown: z
|
||||
.object({
|
||||
eq: z.string().optional().describe('Equals'),
|
||||
neq: z.string().optional().describe('Not equals'),
|
||||
like: z.string().optional().describe('LIKE (% wildcard)'),
|
||||
ilike: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('ILIKE (% wildcard, case-insensitive)'),
|
||||
startsWith: z.string().optional().describe('Starts with'),
|
||||
endsWith: z.string().optional().describe('Ends with'),
|
||||
is: NullCheckEnum.optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const CurrencyFilterSchema = z
|
||||
.object({
|
||||
amountMicros: z
|
||||
|
||||
Reference in New Issue
Block a user