Skip to content

Commit 29c049c

Browse files
authored
feat(condo): DOMA-11178 Add comment response generation using flowise (#6224)
* fix(condo): add polling logic * fix(condo): proof of work * fix(condo): fix button styles * fix(condo): move consts to different files * fix(condo): add typescript typings * fix(condo): fixes on review * fix(condo): fixes on review * fix(condo): add useAIFlow function * fix(condo): add feature flags * fix(condo): add feature flags * fix(condo): add feature flags * fix(condo): fixes on lint review
1 parent 2b1fe3e commit 29c049c

File tree

20 files changed

+520
-69
lines changed

20 files changed

+520
-69
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
.condo-btn.condo-btn-default.condo-btn-secondary.ai-flow-button {
2+
height: 38px;
3+
padding: 8px;
4+
border-color: var(--condo-global-color-green-5);
5+
border-radius: 38px;
6+
}
7+
8+
.condo-btn.condo-btn-default.condo-btn-secondary.ai-flow-button span {
9+
color: var(--condo-global-color-green-5);
10+
font-weight: 600;
11+
font-size: 14px;
12+
}
13+
14+
.condo-btn.condo-btn-default.condo-btn-secondary.ai-flow-button:focus,
15+
.condo-btn.condo-btn-default.condo-btn-secondary.ai-flow-button:active {
16+
outline: none;
17+
box-shadow: none;
18+
}
19+
20+
.condo-btn.condo-btn-default.condo-btn-secondary.ai-flow-button:focus-visible {
21+
outline: 2px solid var(--condo-global-color-green-5);
22+
outline-offset: 2px;
23+
}
24+
25+
button.condo-btn.condo-btn-default.condo-btn-loading.condo-btn-secondary.ai-flow-button .condo-btn-loading-icon {
26+
width: 16px;
27+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import React from 'react'
2+
3+
import { Button, ButtonProps } from '@open-condo/ui'
4+
import './AIFlowButton.css'
5+
6+
type AIFlowButtonProps = Omit<ButtonProps, 'type'>
7+
8+
function Sparkles () {
9+
return (
10+
<svg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'>
11+
<g clipPath='url(#clip0_34298_399)'>
12+
<path fillRule='evenodd' clipRule='evenodd' d='M7.39599 0.869791C7.56986 0.737897 7.78211 0.666504 8.00034 0.666504C8.21858 0.666504 8.43083 0.737897 8.6047 0.869791C8.77857 1.00168 8.90453 1.18684 8.96334 1.397L8.96692 1.4103L10.0209 5.50014C10.0506 5.61549 10.1108 5.72077 10.195 5.80501C10.2792 5.88921 10.3844 5.94932 10.4997 5.9791C10.4997 5.97908 10.4998 5.97911 10.4997 5.9791L14.5897 7.0331L14.6006 7.036C14.8116 7.0942 14.9977 7.22002 15.1303 7.39416C15.2629 7.56829 15.3347 7.78113 15.3347 8C15.3347 8.21888 15.2629 8.43171 15.1303 8.60585C14.9977 8.77998 14.8116 8.9058 14.6006 8.964L14.5897 8.96691L10.4999 10.0209C10.3845 10.0506 10.2792 10.1108 10.195 10.195C10.1108 10.2792 10.0506 10.3845 10.0209 10.4999L8.96623 14.5898L8.96268 14.603C8.90386 14.8132 8.77791 14.9983 8.60404 15.1302C8.43017 15.2621 8.21792 15.3335 7.99968 15.3335C7.78144 15.3335 7.56919 15.2621 7.39532 15.1302C7.22145 14.9983 7.09549 14.8132 7.03668 14.603L7.0331 14.5897L5.97915 10.4999C5.97913 10.4998 5.97916 10.4999 5.97915 10.4999C5.94937 10.3846 5.88922 10.2792 5.80502 10.195C5.72078 10.1108 5.6155 10.0506 5.50015 10.0209L1.41021 8.96622L1.39464 8.962C1.18531 8.90259 1.00106 8.77651 0.869869 8.60289C0.738676 8.42928 0.667694 8.21761 0.667694 8C0.667694 7.7824 0.738676 7.57073 0.869869 7.39711C1.00106 7.2235 1.18531 7.09742 1.39464 7.038L1.41011 7.03381L5.50011 5.97848C5.61543 5.94875 5.7208 5.88864 5.80504 5.80446C5.88923 5.72033 5.94936 5.61519 5.97919 5.49996C5.97917 5.50003 5.97921 5.4999 5.97919 5.49996L7.0338 1.41021L7.03734 1.397C7.09616 1.18684 7.22211 1.00169 7.39599 0.869791ZM8.00024 3.00213L8.72977 5.83304C8.81905 6.17911 8.99947 6.4951 9.2522 6.74782C9.50492 7.00054 9.82074 7.18092 10.1668 7.2702L12.9988 8L10.167 8.72976C9.82091 8.81904 9.50492 8.99947 9.2522 9.25219C8.99947 9.50491 8.81909 9.82073 8.72981 10.1668L7.99978 12.9979L7.27025 10.167C7.18097 9.8209 7.00055 9.50491 6.74783 9.25219C6.49511 8.99947 6.17928 8.81908 5.83321 8.7298L3.00264 7.9999L5.83312 7.26956C5.83308 7.26957 5.83316 7.26955 5.83312 7.26956C6.17902 7.18035 6.49484 7.0001 6.74752 6.7476C7.00023 6.49506 7.18068 6.17945 7.27012 5.83356L8.00024 3.00213Z' fill='#2BC359'/>
13+
<path fillRule='evenodd' clipRule='evenodd' d='M13.3333 1.33325C13.7015 1.33325 14 1.63173 14 1.99992V4.66658C14 5.03477 13.7015 5.33325 13.3333 5.33325C12.9651 5.33325 12.6667 5.03477 12.6667 4.66658V1.99992C12.6667 1.63173 12.9651 1.33325 13.3333 1.33325Z' fill='#2BC359'/>
14+
<path fillRule='evenodd' clipRule='evenodd' d='M11.3333 3.33341C11.3333 2.96522 11.6318 2.66675 12 2.66675H14.6667C15.0349 2.66675 15.3333 2.96522 15.3333 3.33341C15.3333 3.7016 15.0349 4.00008 14.6667 4.00008H12C11.6318 4.00008 11.3333 3.7016 11.3333 3.33341Z' fill='#2BC359'/>
15+
<path fillRule='evenodd' clipRule='evenodd' d='M2.66667 10.6667C3.03486 10.6667 3.33333 10.9652 3.33333 11.3334V12.6667C3.33333 13.0349 3.03486 13.3334 2.66667 13.3334C2.29848 13.3334 2 13.0349 2 12.6667V11.3334C2 10.9652 2.29848 10.6667 2.66667 10.6667Z' fill='#2BC359'/>
16+
<path fillRule='evenodd' clipRule='evenodd' d='M1.33334 11.9999C1.33334 11.6317 1.63182 11.3333 2.00001 11.3333H3.33334C3.70153 11.3333 4.00001 11.6317 4.00001 11.9999C4.00001 12.3681 3.70153 12.6666 3.33334 12.6666H2.00001C1.63182 12.6666 1.33334 12.3681 1.33334 11.9999Z' fill='#2BC359'/>
17+
</g>
18+
<defs>
19+
<clipPath id='clip0_34298_399'>
20+
<rect width='16' height='16' fill='white'/>
21+
</clipPath>
22+
</defs>
23+
</svg>
24+
)
25+
}
26+
27+
export function AIFlowButton ({ children, loading, disabled, ...props }: AIFlowButtonProps & { loading?: boolean, disabled?: boolean }) {
28+
return (
29+
<Button
30+
className='ai-flow-button'
31+
type='secondary'
32+
icon={<Sparkles/>}
33+
loading={loading}
34+
disabled={disabled ? disabled : loading}
35+
{...props}
36+
>
37+
{children}
38+
</Button>
39+
)
40+
}
Lines changed: 24 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
const conf = require('@open-condo/config')
2-
3-
41
const TASK_STATUSES = {
52
PROCESSING: 'processing',
63
COMPLETED: 'completed',
@@ -12,27 +9,8 @@ const FLOW_ADAPTERS = {
129
FLOWISE: 'flowise',
1310
}
1411

15-
/**
16-
*
17-
* @example
18-
* {
19-
* default: {
20-
* example: {
21-
* adapter: 'flowise',
22-
* predictionUrl: 'http://localhost:3000/api/v1/prediction/ed7891c2-19bf-4651-b96a-cdf169ea3dd8',
23-
* },
24-
* },
25-
* custom: {
26-
* my_custom_flow: {
27-
* adapter: 'flowise',
28-
* predictionUrl: 'http://localhost:3000/api/v1/prediction/ed7891c2-19bf-4651-b96a-cdf169ea3dd8',
29-
* },
30-
* },
31-
* }
32-
*/
33-
const AI_FLOWS_CONFIG = conf.AI_FLOWS_CONFIG ? JSON.parse(conf.AI_FLOWS_CONFIG) : {}
34-
3512
const CUSTOM_FLOW_TYPE = 'custom_flow'
13+
const TICKET_REWRITE_COMMENT_FLOW_TYPE = 'ticket_rewrite_comment_flow'
3614

3715
/**
3816
* list of hardcoded flow types
@@ -41,11 +19,10 @@ const CUSTOM_FLOW_TYPE = 'custom_flow'
4119
* EXAMPLE: 'example'
4220
*/
4321
const FLOW_TYPES = {
22+
TICKET_REWRITE_COMMENT_FLOW_TYPE: TICKET_REWRITE_COMMENT_FLOW_TYPE,
4423
}
4524
const FLOW_TYPES_LIST = Object.values(FLOW_TYPES)
4625

47-
const CUSTOM_FLOW_TYPES_LIST = Object.keys(AI_FLOWS_CONFIG?.custom || {})
48-
4926
/**
5027
* Schemes for validating input and output data.
5128
* Syntax Ajv. Only object type.
@@ -76,6 +53,27 @@ const CUSTOM_FLOW_TYPES_LIST = Object.keys(AI_FLOWS_CONFIG?.custom || {})
7653
* },
7754
*/
7855
const FLOW_META_SCHEMAS = {
56+
[TICKET_REWRITE_COMMENT_FLOW_TYPE]: {
57+
input: {
58+
type: 'object',
59+
properties: {
60+
comment: { type: 'string' },
61+
answer: { type: 'string' },
62+
63+
ticketId: { type: 'string' },
64+
ticketDetails: { type: 'string' },
65+
ticketAddress: { type: 'string' },
66+
ticketStatusName: { type: 'string' },
67+
ticketLastComments: { type: 'string' },
68+
},
69+
},
70+
output: {
71+
type: 'object',
72+
properties: {
73+
answer: { type: 'string' },
74+
},
75+
},
76+
},
7977
[CUSTOM_FLOW_TYPE]: {
8078
// Data for custom flows is only checked to ensure that it is an object
8179
input: {
@@ -90,20 +88,17 @@ const FLOW_META_SCHEMAS = {
9088
for (const [flowName, schemaByOperation] of Object.entries(FLOW_META_SCHEMAS)) {
9189
for (const [operation, schema] of Object.entries(schemaByOperation)) {
9290
if (operation !== 'input' && operation !== 'output') throw new Error(`Flow "${flowName}": You can only specify the properties "input" and "output"!`)
93-
if (typeof schema !== 'object') throw new Error(`Flow "${flowName}" (${operation}): The meta schema must be object!`)
91+
if (typeof schema !== 'object') throw new Error(`Flow "${flowName}" (${operation}): The meta schema must be an object!`)
9492
if (!('type' in schema)) throw new Error(`Flow "${flowName}" (${operation}): The meta schema must have a "type" field!`)
9593
if (schema.type !== 'object') throw new Error(`Flow "${flowName}" (${operation}): Field "type" in meta scheme must have value "object"!`)
9694
}
9795
}
9896

99-
10097
module.exports = {
10198
TASK_STATUSES,
10299
FLOW_TYPES,
103100
FLOW_TYPES_LIST,
104-
CUSTOM_FLOW_TYPES_LIST,
105101
FLOW_META_SCHEMAS,
106102
CUSTOM_FLOW_TYPE,
107103
FLOW_ADAPTERS,
108-
AI_FLOWS_CONFIG,
109104
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import {
2+
useCreateExecutionAiFlowTaskMutation,
3+
useGetExecutionAiFlowTaskByIdLazyQuery,
4+
} from '@app/condo/gql'
5+
import getConfig from 'next/config'
6+
import { useState, useCallback } from 'react'
7+
8+
import { getClientSideSenderInfo } from '@open-condo/codegen/utils/userId'
9+
import { useFeatureFlags } from '@open-condo/featureflags/FeatureFlagsContext'
10+
import { useAuth } from '@open-condo/next/auth'
11+
12+
13+
import { FLOW_TYPES_LIST, TASK_STATUSES } from '../constants.js'
14+
15+
type FlowType = typeof FLOW_TYPES_LIST[number]
16+
17+
type UseAIFlowPropsType<T> = {
18+
flowType: FlowType
19+
defaultContext?: object
20+
timeout?: number
21+
}
22+
23+
type UseAIFlowResultType<T> = [
24+
(params?: { context?: object }) => Promise<{ data: T, error: object, localizedErrorText: string } | null>,
25+
{
26+
loading: boolean
27+
data: T | null
28+
error: Error | null
29+
},
30+
]
31+
32+
const DEFAULT_TIMEOUT_MS = 10000
33+
const TASK_FIRST_POLL_TIMEOUT_MS = 500
34+
const TASK_POLLING_INTERVAL_MS = 1000
35+
36+
export function useAIFlow<T = object> ({
37+
flowType,
38+
defaultContext = {},
39+
timeout = DEFAULT_TIMEOUT_MS,
40+
}: UseAIFlowPropsType<T>): UseAIFlowResultType<T> {
41+
const { user } = useAuth()
42+
const [createExecutionAIFlowMutation] = useCreateExecutionAiFlowTaskMutation()
43+
const [getExecutionAiFlowTaskById] = useGetExecutionAiFlowTaskByIdLazyQuery()
44+
45+
const [loading, setLoading] = useState(false)
46+
const [data, setData] = useState<T | null>(null)
47+
const [error, setError] = useState<Error | null>(null)
48+
49+
const getAIFlowResult = useCallback(async ({ context = {} }): Promise<{ data: T, error: object, localizedErrorText: string }> => {
50+
if (!user?.id) {
51+
const err = new Error('User is not authenticated')
52+
setError(err)
53+
return { data: null, error: err, localizedErrorText: null }
54+
}
55+
56+
setLoading(true)
57+
setError(null)
58+
setData(null)
59+
60+
const fullContext = { ...defaultContext, ...context }
61+
62+
try {
63+
const createResult = await createExecutionAIFlowMutation({
64+
variables: {
65+
data: {
66+
dv: 1,
67+
sender: getClientSideSenderInfo(),
68+
flowType,
69+
context: fullContext,
70+
user: { connect: { id: user.id } },
71+
},
72+
},
73+
})
74+
75+
const taskId = createResult.data?.task?.id
76+
if (!taskId) { return { data: null, error: new Error('Failed to create a task'), localizedErrorText: null } }
77+
78+
await new Promise(resolve => setTimeout(resolve, TASK_FIRST_POLL_TIMEOUT_MS))
79+
80+
const startTime = Date.now()
81+
82+
while (Date.now() - startTime < timeout) {
83+
const pollResult = await getExecutionAiFlowTaskById({
84+
variables: { id: taskId },
85+
fetchPolicy: 'no-cache',
86+
})
87+
88+
const [task] = pollResult.data.task
89+
if (!task) { return { data: null, error: new Error('Task not found'), localizedErrorText: null } }
90+
91+
if (task.status === TASK_STATUSES.COMPLETED) {
92+
const result = task.result as T
93+
setData(result)
94+
return { data: result, error: null, localizedErrorText: null }
95+
} else if (task.status === TASK_STATUSES.ERROR || task.status === TASK_STATUSES.CANCELLED) {
96+
return { data: null, error: new Error(`Task in ${task.status} state`), localizedErrorText: task.errorMessage || null }
97+
}
98+
99+
await new Promise(resolve => setTimeout(resolve, TASK_POLLING_INTERVAL_MS))
100+
}
101+
102+
return { data: null, error: new Error('Flow timed out'), localizedErrorText: null }
103+
} catch (err: any) {
104+
const wrappedErr = err instanceof Error ? err : new Error(err.toString())
105+
setError(wrappedErr)
106+
return { data: null, error: wrappedErr, localizedErrorText: null }
107+
} finally {
108+
setLoading(false)
109+
}
110+
}, [
111+
flowType,
112+
timeout,
113+
defaultContext,
114+
createExecutionAIFlowMutation,
115+
getExecutionAiFlowTaskById,
116+
user?.id,
117+
])
118+
119+
return [getAIFlowResult, { loading, data, error }]
120+
}
121+
122+
export function useAIConfig () {
123+
const { publicRuntimeConfig: { aiEnabled } } = getConfig()
124+
const { useFlag } = useFeatureFlags()
125+
126+
const rewriteTicketComment = useFlag('ui-ai-ticket-rewrite-comment')
127+
128+
return {
129+
enabled: aiEnabled,
130+
features: {
131+
rewriteTicketComment,
132+
},
133+
}
134+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
query getExecutionAIFlowTaskById ($id: ID!) {
2+
task: allExecutionAIFlowTasks(
3+
where: { id: $id }
4+
first: 1
5+
) {
6+
id
7+
result
8+
errorMessage
9+
status
10+
__typename
11+
}
12+
}
13+
14+
mutation createExecutionAIFlowTask ($data: ExecutionAIFlowTaskCreateInput!) {
15+
task: createExecutionAIFlowTask(
16+
data: $data
17+
) {
18+
id
19+
result
20+
errorMessage
21+
status
22+
}
23+
}

apps/condo/domains/ai/schema/ExecutionAIFlowTask.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,16 @@ const { historical, versioned, uuided, tracked, softDeleted, dvAndSender } = req
1010
const { GQLListSchema } = require('@open-condo/keystone/schema')
1111
const { extractReqLocale } = require('@open-condo/locales/extractReqLocale')
1212

13+
1314
const access = require('@condo/domains/ai/access/ExecutionAIFlowTask')
1415
const {
1516
TASK_STATUSES,
1617
FLOW_TYPES_LIST,
17-
CUSTOM_FLOW_TYPES_LIST,
1818
FLOW_META_SCHEMAS,
1919
CUSTOM_FLOW_TYPE,
2020
} = require('@condo/domains/ai/constants')
2121
const { executeAIFlow } = require('@condo/domains/ai/tasks')
22+
const { CUSTOM_FLOW_TYPES_LIST } = require('@condo/domains/ai/utils/flowsConfig')
2223
const { WRONG_VALUE } = require('@condo/domains/common/constants/errors')
2324
const { LOCALES } = require('@condo/domains/user/constants/common')
2425
const { RedisGuard } = require('@condo/domains/user/utils/serverSchema/guards')
@@ -38,8 +39,6 @@ try {
3839
EXECUTION_AI_FLOW_TASK_CUSTOM_RATE_LIMITER = {}
3940
}
4041

41-
const redisGuard = new RedisGuard()
42-
4342
const ERRORS = {
4443
UNKNOWN_FLOW_TYPE: {
4544
mutation: 'createExecutionAIFlowTask',
@@ -75,6 +74,8 @@ const ERRORS = {
7574
},
7675
}
7776

77+
const redisGuard = new RedisGuard()
78+
7879
const ajv = new Ajv()
7980

8081
const ExecutionAIFlowTask = new GQLListSchema('ExecutionAIFlowTask', {
@@ -261,4 +262,5 @@ const ExecutionAIFlowTask = new GQLListSchema('ExecutionAIFlowTask', {
261262

262263
module.exports = {
263264
ExecutionAIFlowTask,
265+
ERRORS,
264266
}

apps/condo/domains/ai/tasks/executeAIFlow.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,13 @@ const { FlowiseAdapter } = require('@condo/domains/ai/adapters')
99
const {
1010
TASK_STATUSES,
1111
FLOW_ADAPTERS: FLOW_ADAPTER_NAMES,
12-
AI_FLOWS_CONFIG,
13-
CUSTOM_FLOW_TYPES_LIST,
1412
} = require('@condo/domains/ai/constants')
13+
const { CUSTOM_FLOW_TYPES_LIST, AI_FLOWS_CONFIG } = require('@condo/domains/ai/utils/flowsConfig')
1514
const { ExecutionAIFlowTask } = require('@condo/domains/ai/utils/serverSchema')
1615
const { TASK_WORKER_FINGERPRINT } = require('@condo/domains/common/constants/tasks')
1716

1817
const { FLOW_META_SCHEMAS, CUSTOM_FLOW_TYPE } = require('../constants')
1918

20-
2119
const BASE_ATTRIBUTES = { dv: 1, sender: { dv: 1, fingerprint: TASK_WORKER_FINGERPRINT } }
2220

2321
const FLOW_ADAPTERS = {

0 commit comments

Comments
 (0)