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 django.db import transaction
from cctext import Resolver, Reference, ReferenceType, EntityReference, SyntacticReference
from .utils import fix_old_references
from .models import Constituenta, RSForm
@ -23,6 +25,11 @@ class ExpressionSerializer(serializers.Serializer):
expression = serializers.CharField()
class TextSerializer(serializers.Serializer):
''' Serializer: Text with references. '''
text = serializers.CharField()
class RSFormMetaSerializer(serializers.ModelSerializer):
''' Serializer: General purpose RSForm data. '''
class Meta:
@ -288,7 +295,7 @@ class CstRenameSerializer(serializers.ModelSerializer):
return attrs
class CstListSerlializer(serializers.Serializer):
class CstListSerializer(serializers.Serializer):
''' Serializer: List of constituents from one origin. '''
items = serializers.ListField(
child=CstStandaloneSerializer()
@ -307,6 +314,41 @@ class CstListSerlializer(serializers.Serializer):
return attrs
class CstMoveSerlializer(CstListSerlializer):
class CstMoveSerializer(CstListSerializer):
''' Serializer: Change constituenta position. '''
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.exceptions import ErrorDetail
from cctext import ReferenceType
from apps.users.models import User
from apps.rsform.models import Syntax, RSForm, Constituenta, CstType
from apps.rsform.views import (
@ -20,6 +22,7 @@ def _response_contains(response, schema: RSForm) -> bool:
class TestConstituentaAPI(APITestCase):
''' Testing constituenta view. '''
def setUp(self):
self.factory = APIRequestFactory()
self.user = User.objects.create(username='UserTest')
@ -100,6 +103,7 @@ class TestConstituentaAPI(APITestCase):
class TestRSFormViewset(APITestCase):
''' Testing RSForm view. '''
def setUp(self):
self.factory = APIRequestFactory()
self.user = User.objects.create(username='UserTest')
@ -189,6 +193,34 @@ class TestRSFormViewset(APITestCase):
self.assertEqual(response.data['typification'], 'LOGIC')
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):
work_dir = os.path.dirname(os.path.abspath(__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):
''' Endpoint: Delete multiple constituents. '''
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)
schema.delete_cst(serializer.validated_data['constituents'])
schema.refresh_from_db()
@ -121,7 +121,7 @@ class RSFormViewSet(viewsets.ModelViewSet):
def cst_moveto(self, request, pk):
''' Endpoint: Move multiple constituents. '''
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)
schema.move_cst(serializer.validated_data['constituents'], serializer.validated_data['move_to'])
schema.refresh_from_db()
@ -200,6 +200,17 @@ class RSFormViewSet(viewsets.ModelViewSet):
expression = serializer.validated_data['expression']
result = pyconcept.check_expression(json.dumps(schema.data), expression)
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')
def export_trs(self, request, pk):

View File

@ -5,7 +5,7 @@ from .rumodel import Morphology, SemanticRole, WordTag, morpho, split_grams, com
from .ruparser import PhraseParser, WordToken, Collation
from .reference import EntityReference, ReferenceType, SyntacticReference, parse_reference
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 (
parse, normalize,

View File

@ -9,7 +9,7 @@ function MiniButton({ icon, tooltip, children, noHover, ...props }: MiniButtonPr
return (
<button type='button'
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}
>
{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';
interface TextAreaProps
export interface TextAreaProps
extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'className'> {
label: string
widthClass?: string

View File

@ -61,7 +61,7 @@ function RSInput({
const thisRef = useMemo(
() => {
return innerref ?? internalRef;
}, [internalRef, innerref])
}, [internalRef, innerref]);
const cursor = useMemo(() => editable ? 'cursor-text': 'cursor-default', [editable]);
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(() => {
if (!user) {
setTimeout(() => {
navigate('/library?filter=common');
navigate('/manuals');
}, TIMEOUT_UI_REFRESH);
} else if(!user.is_staff) {
setTimeout(() => {
@ -22,7 +22,7 @@ function HomePage() {
return (
<div className='flex flex-col items-center justify-center w-full py-2'>
<p>Home page</p>
<p>Лендинг находится в разработке. Данная страница видна только пользователям с правами администратора.</p>
</div>
);
}

View File

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

View File

@ -6,7 +6,7 @@ import { config } from './constants'
import {
IConstituentaList, IConstituentaMeta,
ICstCreateData, ICstCreatedResponse, ICstMovetoData, ICstRenameData, ICstUpdateData,
ICurrentUser, IExpressionParse, IRSExpression,
ICurrentUser, IExpressionParse, IReferenceData, IRefsText, IRSExpression,
IRSFormCreateData, IRSFormData,
IRSFormMeta, IRSFormUpdateData, IRSFormUploadData, IUserInfo,
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>) {
AxiosPatch({
title: `Reset alias for RSForm id=${target}`,

View File

@ -31,6 +31,10 @@ export interface IUserUpdatePassword {
}
// ======== RS Parsing ============
export interface IRSExpression {
expression: string
}
export enum Syntax {
UNDEF = 'undefined',
ASCII = 'ascii',
@ -85,8 +89,42 @@ export interface IExpressionParse {
args: IFunctionArg[]
}
export interface IRSExpression {
expression: string
// ====== Reference resolution =====
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 ==========