Implement frontend text refs

This commit is contained in:
IRBorisov 2023-08-23 22:57:25 +03:00
parent 38ffba0af6
commit 890e676a2c
13 changed files with 258 additions and 15 deletions

View File

@ -3,6 +3,8 @@ from typing import Optional, cast
from rest_framework import serializers from rest_framework import serializers
from django.db import transaction from django.db import transaction
from cctext import Resolver, Reference, ReferenceType, EntityReference, SyntacticReference
from .utils import fix_old_references from .utils import fix_old_references
from .models import Constituenta, RSForm from .models import Constituenta, RSForm
@ -23,6 +25,11 @@ class ExpressionSerializer(serializers.Serializer):
expression = serializers.CharField() expression = serializers.CharField()
class TextSerializer(serializers.Serializer):
''' Serializer: Text with references. '''
text = serializers.CharField()
class RSFormMetaSerializer(serializers.ModelSerializer): class RSFormMetaSerializer(serializers.ModelSerializer):
''' Serializer: General purpose RSForm data. ''' ''' Serializer: General purpose RSForm data. '''
class Meta: class Meta:
@ -288,7 +295,7 @@ class CstRenameSerializer(serializers.ModelSerializer):
return attrs return attrs
class CstListSerlializer(serializers.Serializer): class CstListSerializer(serializers.Serializer):
''' Serializer: List of constituents from one origin. ''' ''' Serializer: List of constituents from one origin. '''
items = serializers.ListField( items = serializers.ListField(
child=CstStandaloneSerializer() child=CstStandaloneSerializer()
@ -307,6 +314,41 @@ class CstListSerlializer(serializers.Serializer):
return attrs return attrs
class CstMoveSerlializer(CstListSerlializer): class CstMoveSerializer(CstListSerializer):
''' Serializer: Change constituenta position. ''' ''' Serializer: Change constituenta position. '''
move_to = serializers.IntegerField() move_to = serializers.IntegerField()
class ResolverSerializer(serializers.Serializer):
''' Serializer: Resolver results serializer. '''
def to_representation(self, instance: Resolver) -> dict:
return {
'input': instance.input,
'output': instance.output,
'refs': [{
'type': str(ref.ref.get_type()),
'data': self._get_reference_data(ref.ref),
'resolved': ref.resolved,
'pos_input': {
'start': ref.pos_input.start,
'finish': ref.pos_input.finish
},
'pos_output': {
'start': ref.pos_output.start,
'finish': ref.pos_output.finish
}
} for ref in instance.refs]
}
@staticmethod
def _get_reference_data(ref: Reference) -> dict:
if ref.get_type() == ReferenceType.entity:
return {
'entity': cast(EntityReference, ref).entity,
'form': cast(EntityReference, ref).form
}
else:
return {
'offset': cast(SyntacticReference, ref).offset,
'nominal': cast(SyntacticReference, ref).nominal
}

View File

@ -6,6 +6,8 @@ from zipfile import ZipFile
from rest_framework.test import APITestCase, APIRequestFactory, APIClient from rest_framework.test import APITestCase, APIRequestFactory, APIClient
from rest_framework.exceptions import ErrorDetail from rest_framework.exceptions import ErrorDetail
from cctext import ReferenceType
from apps.users.models import User from apps.users.models import User
from apps.rsform.models import Syntax, RSForm, Constituenta, CstType from apps.rsform.models import Syntax, RSForm, Constituenta, CstType
from apps.rsform.views import ( from apps.rsform.views import (
@ -20,6 +22,7 @@ def _response_contains(response, schema: RSForm) -> bool:
class TestConstituentaAPI(APITestCase): class TestConstituentaAPI(APITestCase):
''' Testing constituenta view. '''
def setUp(self): def setUp(self):
self.factory = APIRequestFactory() self.factory = APIRequestFactory()
self.user = User.objects.create(username='UserTest') self.user = User.objects.create(username='UserTest')
@ -100,6 +103,7 @@ class TestConstituentaAPI(APITestCase):
class TestRSFormViewset(APITestCase): class TestRSFormViewset(APITestCase):
''' Testing RSForm view. '''
def setUp(self): def setUp(self):
self.factory = APIRequestFactory() self.factory = APIRequestFactory()
self.user = User.objects.create(username='UserTest') self.user = User.objects.create(username='UserTest')
@ -189,6 +193,34 @@ class TestRSFormViewset(APITestCase):
self.assertEqual(response.data['typification'], 'LOGIC') self.assertEqual(response.data['typification'], 'LOGIC')
self.assertEqual(response.data['valueClass'], 'value') self.assertEqual(response.data['valueClass'], 'value')
def test_resolve(self):
schema = RSForm.objects.create(title='Test')
x1 = schema.insert_at(1, 'X1', CstType.BASE)
x1.term_resolved = 'синий слон'
x1.save()
data = json.dumps({'text': '@{1|редкий} @{X1|plur,datv}'})
response = self.client.post(f'/api/rsforms/{schema.id}/resolve/', data=data, content_type='application/json')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['input'], '@{1|редкий} @{X1|plur,datv}')
self.assertEqual(response.data['output'], 'редким синим слонам')
self.assertEqual(len(response.data['refs']), 2)
self.assertEqual(response.data['refs'][0]['type'], str(ReferenceType.syntactic))
self.assertEqual(response.data['refs'][0]['resolved'], 'редким')
self.assertEqual(response.data['refs'][0]['data']['offset'], 1)
self.assertEqual(response.data['refs'][0]['data']['nominal'], 'редкий')
self.assertEqual(response.data['refs'][0]['pos_input']['start'], 0)
self.assertEqual(response.data['refs'][0]['pos_input']['finish'], 11)
self.assertEqual(response.data['refs'][0]['pos_output']['start'], 0)
self.assertEqual(response.data['refs'][0]['pos_output']['finish'], 6)
self.assertEqual(response.data['refs'][1]['type'], str(ReferenceType.entity))
self.assertEqual(response.data['refs'][1]['resolved'], 'синим слонам')
self.assertEqual(response.data['refs'][1]['data']['entity'], 'X1')
self.assertEqual(response.data['refs'][1]['data']['form'], 'plur,datv')
self.assertEqual(response.data['refs'][1]['pos_input']['start'], 12)
self.assertEqual(response.data['refs'][1]['pos_input']['finish'], 27)
self.assertEqual(response.data['refs'][1]['pos_output']['start'], 7)
self.assertEqual(response.data['refs'][1]['pos_output']['finish'], 19)
def test_import_trs(self): def test_import_trs(self):
work_dir = os.path.dirname(os.path.abspath(__file__)) work_dir = os.path.dirname(os.path.abspath(__file__))
with open(f'{work_dir}/data/sample-rsform.trs', 'rb') as file: with open(f'{work_dir}/data/sample-rsform.trs', 'rb') as file:

View File

@ -111,7 +111,7 @@ class RSFormViewSet(viewsets.ModelViewSet):
def cst_multidelete(self, request, pk): def cst_multidelete(self, request, pk):
''' Endpoint: Delete multiple constituents. ''' ''' Endpoint: Delete multiple constituents. '''
schema = self._get_schema() schema = self._get_schema()
serializer = serializers.CstListSerlializer(data=request.data, context={'schema': schema}) serializer = serializers.CstListSerializer(data=request.data, context={'schema': schema})
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
schema.delete_cst(serializer.validated_data['constituents']) schema.delete_cst(serializer.validated_data['constituents'])
schema.refresh_from_db() schema.refresh_from_db()
@ -121,7 +121,7 @@ class RSFormViewSet(viewsets.ModelViewSet):
def cst_moveto(self, request, pk): def cst_moveto(self, request, pk):
''' Endpoint: Move multiple constituents. ''' ''' Endpoint: Move multiple constituents. '''
schema = self._get_schema() schema = self._get_schema()
serializer = serializers.CstMoveSerlializer(data=request.data, context={'schema': schema}) serializer = serializers.CstMoveSerializer(data=request.data, context={'schema': schema})
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
schema.move_cst(serializer.validated_data['constituents'], serializer.validated_data['move_to']) schema.move_cst(serializer.validated_data['constituents'], serializer.validated_data['move_to'])
schema.refresh_from_db() schema.refresh_from_db()
@ -201,6 +201,17 @@ class RSFormViewSet(viewsets.ModelViewSet):
result = pyconcept.check_expression(json.dumps(schema.data), expression) result = pyconcept.check_expression(json.dumps(schema.data), expression)
return Response(json.loads(result)) return Response(json.loads(result))
@action(detail=True, methods=['post'])
def resolve(self, request, pk):
''' Endpoint: Resolve refenrces in text against schema terms context. '''
schema = self._get_schema()
serializer = serializers.TextSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
text = serializer.validated_data['text']
resolver = schema.resolver()
resolver.resolve(text)
return Response(status=200, data=serializers.ResolverSerializer(resolver).data)
@action(detail=True, methods=['get'], url_path='export-trs') @action(detail=True, methods=['get'], url_path='export-trs')
def export_trs(self, request, pk): def export_trs(self, request, pk):
''' Endpoint: Download Exteor compatible file. ''' ''' Endpoint: Download Exteor compatible file. '''

View File

@ -5,7 +5,7 @@ from .rumodel import Morphology, SemanticRole, WordTag, morpho, split_grams, com
from .ruparser import PhraseParser, WordToken, Collation from .ruparser import PhraseParser, WordToken, Collation
from .reference import EntityReference, ReferenceType, SyntacticReference, parse_reference from .reference import EntityReference, ReferenceType, SyntacticReference, parse_reference
from .context import TermForm, Entity, TermContext from .context import TermForm, Entity, TermContext
from .resolver import Position, Resolver, ResolvedReference, resolve_entity, resolve_syntactic, extract_entities from .resolver import Reference, Position, Resolver, ResolvedReference, resolve_entity, resolve_syntactic, extract_entities
from .conceptapi import ( from .conceptapi import (
parse, normalize, parse, normalize,

View File

@ -9,7 +9,7 @@ function MiniButton({ icon, tooltip, children, noHover, ...props }: MiniButtonPr
return ( return (
<button type='button' <button type='button'
title={tooltip} title={tooltip}
className={`px-1 py-1 font-bold rounded-full cursor-pointer whitespace-nowrap disabled:cursor-not-allowed clr-btn-clear ${noHover ? '' : 'clr-hover'}`} className={`px-1 py-1 font-bold rounded-full cursor-pointer whitespace-nowrap disabled:cursor-not-allowed clr-btn-clear ${noHover ? 'outline-none' : 'clr-hover'}`}
{...props} {...props}
> >
{icon && <span>{icon}</span>} {icon && <span>{icon}</span>}

View File

@ -0,0 +1,76 @@
import { useState } from 'react';
import { useRSForm } from '../../context/RSFormContext';
import useResolveText from '../../hooks/useResolveText';
import Modal from './Modal';
import PrettyJson from './PrettyJSON';
import TextArea, { TextAreaProps } from './TextArea';
interface ReferenceInputProps
extends TextAreaProps {
initialValue?: string
value?: string
resolved?: string
}
function ReferenceInput({
initialValue, resolved, value,
onKeyDown, onChange, onFocus, onBlur, ... props
}: ReferenceInputProps) {
const { schema } = useRSForm();
const { resolveText, refsData } = useResolveText({schema: schema});
const [showResolve, setShowResolve] = useState(false);
const [isFocused, setIsFocused] = useState(false);
function handleKeyboard(event: React.KeyboardEvent<HTMLTextAreaElement>) {
if (event.altKey) {
if (event.key === 'r' && value) {
event.preventDefault();
resolveText(value, () => {
setShowResolve(true);
});
return;
}
}
if (onKeyDown) onKeyDown(event);
}
function handleChange(event: React.ChangeEvent<HTMLTextAreaElement>) {
if (onChange) onChange(event);
}
function handleFocusIn(event: React.FocusEvent<HTMLTextAreaElement>) {
setIsFocused(true);
if (onFocus) onFocus(event);
}
function handleFocusOut(event: React.FocusEvent<HTMLTextAreaElement>) {
setIsFocused(false);
if (onBlur) onBlur(event);
}
return (
<>
{ showResolve &&
<Modal
readonly
hideWindow={() => setShowResolve(false)}
>
<div className='max-h-[60vh] max-w-[80vw] overflow-auto'>
<PrettyJson data={refsData} />
</div>
</Modal>}
<TextArea
value={isFocused ? value : (value !== initialValue ? value : resolved)}
onKeyDown={handleKeyboard}
onChange={handleChange}
onFocus={handleFocusIn}
onBlur={handleFocusOut}
{...props}
/>
</>
);
}
export default ReferenceInput;

View File

@ -2,7 +2,7 @@ import { TextareaHTMLAttributes } from 'react';
import Label from './Label'; import Label from './Label';
interface TextAreaProps export interface TextAreaProps
extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'className'> { extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'className'> {
label: string label: string
widthClass?: string widthClass?: string

View File

@ -61,7 +61,7 @@ function RSInput({
const thisRef = useMemo( const thisRef = useMemo(
() => { () => {
return innerref ?? internalRef; return innerref ?? internalRef;
}, [internalRef, innerref]) }, [internalRef, innerref]);
const cursor = useMemo(() => editable ? 'cursor-text': 'cursor-default', [editable]); const cursor = useMemo(() => editable ? 'cursor-text': 'cursor-default', [editable]);
const lightTheme: Extension = useMemo( const lightTheme: Extension = useMemo(

View File

@ -0,0 +1,31 @@
import { useCallback, useState } from 'react'
import { ErrorInfo } from '../components/BackendError';
import { DataCallback, postResolveText } from '../utils/backendAPI';
import { IReferenceData,IRSForm } from '../utils/models';
function useResolveText({ schema }: { schema?: IRSForm }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<ErrorInfo>(undefined);
const [refsData, setRefsData] = useState<IReferenceData | undefined>(undefined);
const resetData = useCallback(() => { setRefsData(undefined); }, []);
function resolveText(text: string, onSuccess?: DataCallback<IReferenceData>) {
setError(undefined);
postResolveText(String(schema!.id), {
data: { text: text },
showError: true,
setLoading,
onError: error => { setError(error); },
onSuccess: data => {
setRefsData(data);
if (onSuccess) onSuccess(data);
}
});
}
return { refsData, resolveText, resetData, error, setError, loading };
}
export default useResolveText;

View File

@ -11,7 +11,7 @@ function HomePage() {
useLayoutEffect(() => { useLayoutEffect(() => {
if (!user) { if (!user) {
setTimeout(() => { setTimeout(() => {
navigate('/library?filter=common'); navigate('/manuals');
}, TIMEOUT_UI_REFRESH); }, TIMEOUT_UI_REFRESH);
} else if(!user.is_staff) { } else if(!user.is_staff) {
setTimeout(() => { setTimeout(() => {
@ -22,7 +22,7 @@ function HomePage() {
return ( return (
<div className='flex flex-col items-center justify-center w-full py-2'> <div className='flex flex-col items-center justify-center w-full py-2'>
<p>Home page</p> <p>Лендинг находится в разработке. Данная страница видна только пользователям с правами администратора.</p>
</div> </div>
); );
} }

View File

@ -3,6 +3,7 @@ import { toast } from 'react-toastify';
import ConceptTooltip from '../../components/Common/ConceptTooltip'; import ConceptTooltip from '../../components/Common/ConceptTooltip';
import MiniButton from '../../components/Common/MiniButton'; import MiniButton from '../../components/Common/MiniButton';
import ReferenceInput from '../../components/Common/ReferenceInput';
import SubmitButton from '../../components/Common/SubmitButton'; import SubmitButton from '../../components/Common/SubmitButton';
import TextArea from '../../components/Common/TextArea'; import TextArea from '../../components/Common/TextArea';
import HelpConstituenta from '../../components/Help/HelpConstituenta'; import HelpConstituenta from '../../components/Help/HelpConstituenta';
@ -171,10 +172,12 @@ function EditorConstituenta({ activeID, onShowAST, onCreateCst, onRenameCst, onO
icon={<PenIcon size={4} color={isEnabled ? 'text-primary' : ''} />} icon={<PenIcon size={4} color={isEnabled ? 'text-primary' : ''} />}
/> />
</div> </div>
<TextArea id='term' label='Термин' <ReferenceInput id='term' label='Термин'
placeholder='Схемный или предметный термин, обозначающий данное понятие или утверждение' placeholder='Схемный или предметный термин, обозначающий данное понятие или утверждение'
rows={2} rows={2}
value={term} value={term}
initialValue={activeCst?.term.raw ?? ''}
resolved={activeCst?.term.resolved ?? ''}
disabled={!isEnabled} disabled={!isEnabled}
spellCheck spellCheck
onChange={event => setTerm(event.target.value)} onChange={event => setTerm(event.target.value)}
@ -197,10 +200,12 @@ function EditorConstituenta({ activeID, onShowAST, onCreateCst, onRenameCst, onO
setValue={setExpression} setValue={setExpression}
setTypification={setTypification} setTypification={setTypification}
/> />
<TextArea id='definition' label='Текстовое определение' <ReferenceInput id='definition' label='Текстовое определение'
placeholder='Лингвистическая интерпретация формального выражения' placeholder='Лингвистическая интерпретация формального выражения'
rows={4} rows={4}
value={textDefinition} value={textDefinition}
initialValue={activeCst?.definition.text.raw ?? ''}
resolved={activeCst?.definition.text.resolved ?? ''}
disabled={!isEnabled} disabled={!isEnabled}
spellCheck spellCheck
onChange={event => { setTextDefinition(event.target.value); }} onChange={event => { setTextDefinition(event.target.value); }}

View File

@ -6,7 +6,7 @@ import { config } from './constants'
import { import {
IConstituentaList, IConstituentaMeta, IConstituentaList, IConstituentaMeta,
ICstCreateData, ICstCreatedResponse, ICstMovetoData, ICstRenameData, ICstUpdateData, ICstCreateData, ICstCreatedResponse, ICstMovetoData, ICstRenameData, ICstUpdateData,
ICurrentUser, IExpressionParse, IRSExpression, ICurrentUser, IExpressionParse, IReferenceData, IRefsText, IRSExpression,
IRSFormCreateData, IRSFormData, IRSFormCreateData, IRSFormData,
IRSFormMeta, IRSFormUpdateData, IRSFormUploadData, IUserInfo, IRSFormMeta, IRSFormUpdateData, IRSFormUploadData, IUserInfo,
IUserLoginData, IUserProfile, IUserSignupData, IUserUpdateData, IUserUpdatePassword IUserLoginData, IUserProfile, IUserSignupData, IUserUpdateData, IUserUpdatePassword
@ -234,6 +234,14 @@ export function postCheckExpression(schema: string, request: FrontExchange<IRSEx
}); });
} }
export function postResolveText(schema: string, request: FrontExchange<IRefsText, IReferenceData>) {
AxiosPost({
title: `Resolve text references for RSForm id=${schema}: ${request.data.text }`,
endpoint: `/api/rsforms/${schema}/resolve/`,
request: request
});
}
export function patchResetAliases(target: string, request: FrontPull<IRSFormData>) { export function patchResetAliases(target: string, request: FrontPull<IRSFormData>) {
AxiosPatch({ AxiosPatch({
title: `Reset alias for RSForm id=${target}`, title: `Reset alias for RSForm id=${target}`,

View File

@ -31,6 +31,10 @@ export interface IUserUpdatePassword {
} }
// ======== RS Parsing ============ // ======== RS Parsing ============
export interface IRSExpression {
expression: string
}
export enum Syntax { export enum Syntax {
UNDEF = 'undefined', UNDEF = 'undefined',
ASCII = 'ascii', ASCII = 'ascii',
@ -85,8 +89,42 @@ export interface IExpressionParse {
args: IFunctionArg[] args: IFunctionArg[]
} }
export interface IRSExpression { // ====== Reference resolution =====
expression: string export interface IRefsText {
text: string
}
export enum ReferenceType {
ENTITY = 'entity',
SYNTACTIC = 'syntax'
}
export interface IEntityReference {
entity: string
form: string
}
export interface ISyntacticReference {
offset: number
nominal: string
}
export interface ITextPosition {
start: number
finish: number
}
export interface IResolvedReference {
type: ReferenceType
data: IEntityReference | ISyntacticReference
pos_input: ITextPosition
pos_output: ITextPosition
}
export interface IReferenceData {
input: string
output: string
refs: IResolvedReference[]
} }
// ====== Constituenta ========== // ====== Constituenta ==========