F: Implementing template evaluation
Some checks are pending
Frontend CI / build (22.x) (push) Waiting to run
Frontend CI / notify-failure (push) Blocked by required conditions

This commit is contained in:
Ivan 2025-07-15 23:50:57 +03:00
parent ca150d3bd3
commit b2185b7e39
11 changed files with 230 additions and 83 deletions

View File

@ -25,7 +25,7 @@ export function MenuAI() {
event.preventDefault();
event.stopPropagation();
menu.hide();
showAIPrompt({});
showAIPrompt();
}
return (

View File

@ -1,76 +0,0 @@
import { useState } from 'react';
import { ModalForm } from '@/components/modal';
import { type IPromptTemplate } from '../backend/types';
export interface DlgAIPromptDialogProps {
onPromptSelected?: (prompt: IPromptTemplate) => void;
}
const mockPrompts: IPromptTemplate[] = [
{
id: 1,
owner: null,
label: 'Greeting',
is_shared: true,
description: 'A simple greeting prompt.',
text: 'Hello, ${name}! How can I assist you today?'
},
{
id: 2,
owner: null,
is_shared: true,
label: 'Summary',
description: 'Summarize the following text.',
text: 'Please summarize the following: ${text}'
}
];
export function DlgAIPromptDialog() {
const [selectedPrompt, setSelectedPrompt] = useState<IPromptTemplate | null>(mockPrompts[0]);
return (
<ModalForm
header='AI Prompt Generator'
submitText='Generate Prompt'
canSubmit={!!selectedPrompt}
onSubmit={e => {
e.preventDefault();
// Placeholder for generate logic
}}
className='w-120 px-6 cc-column'
>
<div className='mb-4'>
<label htmlFor='prompt-select' className='block mb-2 font-semibold'>
Select a prompt:
</label>
<select
id='prompt-select'
className='w-full border rounded p-2'
value={selectedPrompt?.id}
onChange={e => {
const prompt = mockPrompts.find(p => String(p.id) === e.target.value) || null;
setSelectedPrompt(prompt);
}}
>
{mockPrompts.map(prompt => (
<option key={prompt.id} value={prompt.id}>
{prompt.label}
</option>
))}
</select>
</div>
{selectedPrompt && (
<div className='mb-4'>
<div className='font-semibold'>Label:</div>
<div className='mb-2'>{selectedPrompt.label}</div>
<div className='font-semibold'>Description:</div>
<div className='mb-2'>{selectedPrompt.description}</div>
<div className='font-semibold'>Template Text:</div>
<pre className='bg-gray-100 p-2 rounded'>{selectedPrompt.text}</pre>
</div>
)}
</ModalForm>
);
}

View File

@ -0,0 +1,43 @@
import { Suspense, useState } from 'react';
import { Loader } from '@/components/loader';
import { ModalView } from '@/components/modal';
import { TabLabel, TabList, TabPanel, Tabs } from '@/components/tabs';
import { TabPromptResult } from './tab-prompt-result';
import { TabPromptSelect } from './tab-prompt-select';
import { TabPromptVariables } from './tab-prompt-variables';
export const TabID = {
SELECT: 0,
RESULT: 1,
VARIABLES: 2
} as const;
type TabID = (typeof TabID)[keyof typeof TabID];
export function DlgAIPromptDialog() {
const [activeTab, setActiveTab] = useState<TabID>(TabID.SELECT);
const [selectedPromptId, setSelectedPromptId] = useState<number | null>(null);
return (
<ModalView header='Генератор запросом LLM' className='w-160 px-6 cc-column'>
<Tabs selectedIndex={activeTab} onSelect={index => setActiveTab(index as TabID)}>
<TabList className='mb-3 mx-auto w-fit flex border divide-x rounded-none'>
<TabLabel label='Шаблон' />
<TabLabel label='Результат' disabled={!selectedPromptId} />
<TabLabel label='Переменные' disabled={!selectedPromptId} />
</TabList>
<div className='h-100'>
<Suspense fallback={<Loader />}>
<TabPanel>
<TabPromptSelect selected={selectedPromptId} setSelected={setSelectedPromptId} />
</TabPanel>
<TabPanel>{selectedPromptId ? <TabPromptResult promptID={selectedPromptId} /> : null}</TabPanel>
<TabPanel>{selectedPromptId ? <TabPromptVariables promptID={selectedPromptId} /> : null}</TabPanel>
</Suspense>
</div>
</Tabs>
</ModalView>
);
}

View File

@ -0,0 +1 @@
export * from './dlg-ai-prompt';

View File

@ -0,0 +1,66 @@
import { useMemo } from 'react';
import { toast } from 'react-toastify';
import { MiniButton } from '@/components/control';
import { IconClone } from '@/components/icons';
import { TextArea } from '@/components/input';
import { infoMsg } from '@/utils/labels';
import { usePromptTemplateSuspense } from '../../backend/use-prompt-template';
import { PromptVariableType } from '../../models/prompting';
import { extractPromptVariables } from '../../models/prompting-api';
import { evaluatePromptVariable, useAIStore } from '../../stores/ai-context';
interface TabPromptResultProps {
promptID: number;
}
export function TabPromptResult({ promptID }: TabPromptResultProps) {
const { promptTemplate } = usePromptTemplateSuspense(promptID);
const context = useAIStore();
const variables = useMemo(() => {
return promptTemplate ? extractPromptVariables(promptTemplate.text) : [];
}, [promptTemplate]);
const generatedMessage = (() => {
if (!promptTemplate) {
return '';
}
let result = promptTemplate.text;
for (const variable of variables) {
const type = Object.values(PromptVariableType).find(t => t === variable);
let value = '';
if (type) {
value = evaluatePromptVariable(type, context) ?? '';
}
result = result.replace(new RegExp(`\{\{${variable}\}\}`, 'g'), value || `${variable}`);
}
return result;
})();
function handleCopyPrompt() {
void navigator.clipboard.writeText(generatedMessage);
toast.success(infoMsg.promptReady);
}
return (
<div className='mb-4 relative'>
<MiniButton
title='Скопировать в буфер обмена'
className='absolute -top-10 left-0'
icon={<IconClone size='1.25rem' className='icon-green' />}
onClick={handleCopyPrompt}
disabled={!generatedMessage}
/>
<TextArea
aria-label='Сгенерированное сообщение'
value={generatedMessage}
placeholder='Текст шаблона пуст'
disabled
noBorder
fitContent
className='w-full max-h-80 min-h-12'
/>
</div>
);
}

View File

@ -0,0 +1,45 @@
import { TextArea } from '@/components/input';
import { ComboBox } from '@/components/input/combo-box';
import { useAvailableTemplatesSuspense } from '../../backend/use-available-templates';
import { usePromptTemplateSuspense } from '../../backend/use-prompt-template';
interface TabPromptSelectProps {
selected: number | null;
setSelected: (id: number) => void;
}
export function TabPromptSelect({ selected, setSelected }: TabPromptSelectProps) {
const { items: prompts } = useAvailableTemplatesSuspense();
const { promptTemplate } = usePromptTemplateSuspense(selected ?? prompts?.[0]?.id ?? 0);
return (
<div className='cc-column'>
<ComboBox
id='prompt-select'
items={prompts}
value={prompts?.find(p => p.id === selected) ?? null}
onChange={item => setSelected(item?.id ?? 0)}
idFunc={item => String(item.id)}
labelValueFunc={item => item.label}
labelOptionFunc={item => item.label}
placeholder='Выберите шаблон'
className='w-full'
/>
{promptTemplate && (
<div className='flex flex-col gap-2'>
<TextArea id='prompt-label' label='Название' value={promptTemplate.label} disabled noResize rows={1} />
<TextArea
id='prompt-description'
label='Описание'
value={promptTemplate.description}
disabled
noResize
rows={2}
/>
<TextArea id='prompt-text' label='Текст шаблона' value={promptTemplate.text} disabled noResize rows={4} />
</div>
)}
</div>
);
}

View File

@ -0,0 +1,35 @@
'use client';
import { usePromptTemplateSuspense } from '../../backend/use-prompt-template';
import { describePromptVariable } from '../../labels';
import { PromptVariableType } from '../../models/prompting';
import { extractPromptVariables } from '../../models/prompting-api';
import { useAvailableVariables } from '../../stores/use-available-variables';
interface TabPromptVariablesProps {
promptID: number;
}
export function TabPromptVariables({ promptID }: TabPromptVariablesProps) {
const { promptTemplate } = usePromptTemplateSuspense(promptID);
const variables = extractPromptVariables(promptTemplate.text);
const availableTypes = useAvailableVariables();
return (
<div className='mb-4'>
<div className='font-semibold mb-2'>Переменные шаблона:</div>
<ul>
{variables.length === 0 && <li>Нет переменных</li>}
{variables.map(variable => {
const type = Object.values(PromptVariableType).find(t => t === variable);
const isAvailable = !!type && availableTypes.includes(type);
return (
<li key={variable} className={isAvailable ? 'text-green-700 font-bold' : 'text-gray-500'}>
{variable} {type ? describePromptVariable(type) : 'Неизвестная переменная'}
{isAvailable ? ' (доступна)' : ' (нет в контексте)'}
</li>
);
})}
</ul>
</div>
);
}

View File

@ -2,6 +2,7 @@ import { create } from 'zustand';
import { type IBlock, type IOperationSchema } from '@/features/oss/models/oss';
import { type IConstituenta, type IRSForm } from '@/features/rsform';
import { labelCstTypification } from '@/features/rsform/labels';
import { PromptVariableType } from '../models/prompting';
@ -55,10 +56,23 @@ export function evaluatePromptVariable(variableType: PromptVariableType, context
case PromptVariableType.OSS:
return context.currentOSS?.title ?? '';
case PromptVariableType.SCHEMA:
return context.currentSchema?.title ?? '';
return context.currentSchema ? generateSchemaPrompt(context.currentSchema) : '';
case PromptVariableType.BLOCK:
return context.currentBlock?.title ?? '';
case PromptVariableType.CONSTITUENTA:
return context.currentConstituenta?.alias ?? '';
}
}
// ====== Internals =========
function generateSchemaPrompt(schema: IRSForm): string {
let body = `Название концептуальной схемы: ${schema.title}\n`;
body += `[${schema.alias}] Описание: "${schema.description}"\n\n`;
body += 'Понятия:\n';
schema.items.forEach(item => {
body += `${item.alias} - "${labelCstTypification(item)}" - "${item.term_resolved}" - "${
item.definition_formal
}" - "${item.definition_resolved}" - "${item.convention}"\n`;
});
return body;
}

View File

@ -1,8 +1,9 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { urls, useConceptNavigation } from '@/app';
import { useAIStore } from '@/features/ai/stores/ai-context';
import { useAuthSuspense } from '@/features/auth';
import { useLibrarySearchStore } from '@/features/library';
import { useDeleteItem } from '@/features/library/backend/use-delete-item';
@ -30,6 +31,7 @@ export const OssEditState = ({ itemID, children }: React.PropsWithChildren<OssEd
const role = useRoleStore(state => state.role);
const setSearchLocation = useLibrarySearchStore(state => state.setLocation);
const searchLocation = useLibrarySearchStore(state => state.location);
const setCurrentOSS = useAIStore(state => state.setCurrentOSS);
const { user } = useAuthSuspense();
const { schema } = useOssSuspense({ itemID: itemID });
@ -50,6 +52,11 @@ export const OssEditState = ({ itemID, children }: React.PropsWithChildren<OssEd
adminMode: adminMode
});
useEffect(() => {
setCurrentOSS(schema);
return () => setCurrentOSS(null);
}, [schema, setCurrentOSS]);
function navigateTab(tab: OssTabID) {
const url = urls.oss_props({
id: schema.id,

View File

@ -1,8 +1,9 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { urls, useConceptNavigation } from '@/app';
import { useAIStore } from '@/features/ai/stores/ai-context';
import { useAuthSuspense } from '@/features/auth';
import { useLibrarySearchStore } from '@/features/library';
import { useDeleteItem } from '@/features/library/backend/use-delete-item';
@ -68,6 +69,8 @@ export const RSEditState = ({
const showCreateCst = useDialogsStore(state => state.showCreateCst);
const showDeleteCst = useDialogsStore(state => state.showDeleteCst);
const showCstTemplate = useDialogsStore(state => state.showCstTemplate);
const setCurrentSchema = useAIStore(state => state.setCurrentSchema);
const setCurrentConstituenta = useAIStore(state => state.setCurrentConstituenta);
useAdjustRole({
isOwner: isOwned,
@ -76,6 +79,16 @@ export const RSEditState = ({
adminMode: adminMode
});
useEffect(() => {
setCurrentSchema(schema);
return () => setCurrentSchema(null);
}, [schema, setCurrentSchema]);
useEffect(() => {
setCurrentConstituenta(activeCst);
return () => setCurrentConstituenta(null);
}, [activeCst, setCurrentConstituenta]);
function handleSetFocus(newValue: IConstituenta | null) {
setFocusCst(newValue);
setSelected([]);

View File

@ -1,6 +1,5 @@
import { create } from 'zustand';
import { type DlgAIPromptDialogProps } from '@/features/ai/dialogs/dlg-ai-prompt';
import { type DlgCreatePromptTemplateProps } from '@/features/ai/dialogs/dlg-create-prompt-template';
import { type DlgChangeLocationProps } from '@/features/library/dialogs/dlg-change-location';
import { type DlgCloneLibraryItemProps } from '@/features/library/dialogs/dlg-clone-library-item';
@ -115,7 +114,7 @@ interface DialogsStore {
showEditCst: (props: DlgEditCstProps) => void;
showCreateSchema: (props: DlgCreateSchemaProps) => void;
showImportSchema: (props: DlgImportSchemaProps) => void;
showAIPrompt: (props: DlgAIPromptDialogProps) => void;
showAIPrompt: () => void;
showCreatePromptTemplate: (props: DlgCreatePromptTemplateProps) => void;
}
@ -159,6 +158,6 @@ export const useDialogsStore = create<DialogsStore>()(set => ({
showEditCst: props => set({ active: DialogType.EDIT_CONSTITUENTA, props: props }),
showCreateSchema: props => set({ active: DialogType.CREATE_SCHEMA, props: props }),
showImportSchema: props => set({ active: DialogType.IMPORT_SCHEMA, props: props }),
showAIPrompt: (props: DlgAIPromptDialogProps) => set({ active: DialogType.AI_PROMPT, props: props }),
showAIPrompt: () => set({ active: DialogType.AI_PROMPT, props: null }),
showCreatePromptTemplate: props => set({ active: DialogType.CREATE_PROMPT_TEMPLATE, props: props })
}));