Commit b147d620 authored by Konstantin Schulz's avatar Konstantin Schulz
Browse files

H5P exercises from Zenodo can now be processed in the Machina Callida

parent e4786a46
Pipeline #16936 passed with stages
in 6 minutes and 27 seconds
......@@ -14,3 +14,4 @@ __pycache__/
.pydevproject
.pylintrc
.vscode/
__open_alchemy_*
......@@ -32,8 +32,6 @@ Or combine both commands in one line: `pip list -o --format=freeze | grep -v '^\
----------------------------------------------------------------
# Database
To use the database for the first time:
If you use Postgres, you need to create the database "callidus" manually: `CREATE DATABASE callidus;`
To autogenerate a new migration script:
1. Start the Docker container with the database: `docker-compose up -p 5432:5432 -d db`
2. Create a new migration: `flask db migrate`.
......@@ -51,4 +49,4 @@ To generate class structures for this project automatically:
2. Run: `openapi-generator generate -i ./mcserver/mcserver_api.yaml -g python-flask -o ./openapi/ && python openapi_generator.py`.
# Testing
To check the coverage of the current tests, run
`coverage run --rcfile=.coveragerc tests.py && coverage combine && coverage report -m`.
`coverage run --rcfile=.coveragerc tests.py && coverage combine && coverage report -m`.
\ No newline at end of file
......@@ -18,6 +18,11 @@ from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
from graphannis.cs import CorpusStorageManager
from open_alchemy import init_yaml
from sqlalchemy import create_engine
from sqlalchemy.engine import Connection, Engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy_utils import database_exists
from mcserver.config import Config
......@@ -61,11 +66,27 @@ def create_app(cfg: Type[Config] = Config) -> Flask:
return app
def create_database() -> SQLAlchemy:
def create_database_handler() -> SQLAlchemy:
"""Creates a new connection to a database, which will handle all future database transactions."""
return SQLAlchemy() # session_options={"autocommit": True}
def create_postgres_database(cfg: Type[Config] = Config) -> None:
""" Creates a new Postgres database. """
idx: int = cfg.SQLALCHEMY_DATABASE_URI.rindex("/")
# remove database name from the URI
uri_without_db: str = cfg.SQLALCHEMY_DATABASE_URI[:idx]
engine: Engine = create_engine(uri_without_db)
session: Session = sessionmaker(bind=engine)()
connection: Connection = session.connection()
# database creation must be outside of a transaction
connection.connection.set_isolation_level(0)
db_name: str = cfg.SQLALCHEMY_DATABASE_URI.split("/")[-1]
session.execute(f'CREATE DATABASE {db_name}')
session.close()
engine.dispose()
def full_init(app: Flask, cfg: Type[Config] = Config) -> None:
""" Fully initializes the application, including logging."""
from mcserver.app.services import DatabaseService
......@@ -108,6 +129,9 @@ def init_app_common(cfg: Type[Config] = Config) -> Flask:
app.config.from_object(cfg)
app.app_context().push()
init_logging(app, Config.LOG_PATH_MCSERVER)
# in Postgres, databases are not created automatically, so we have to check manually
if not cfg.TESTING and not database_exists(cfg.SQLALCHEMY_DATABASE_URI):
create_postgres_database(cfg)
db.init_app(app)
migrate.init_app(app, db)
db.create_all()
......@@ -160,7 +184,7 @@ def shutdown_session(exception=None):
db.session.remove()
db: SQLAlchemy = create_database()
db: SQLAlchemy = create_database_handler()
migrate: Migrate = Migrate(directory=Config.MIGRATIONS_DIRECTORY)
if not hasattr(open_alchemy.models, Config.DATABASE_TABLE_CORPUS):
# initialize the database and models _BEFORE_ you add any APIs to your application
......
from io import BytesIO
import json
import os
import shutil
import zipfile
from typing import List, Union, Set, Any
from zipfile import ZipFile
import connexion
import requests
from connexion.lifecycle import ConnexionResponse
from flask import Response, send_from_directory
from mcserver import Config
from mcserver.app.models import Language, ExerciseType, Solution, MimeType, FileType
from mcserver.app.services import TextService, NetworkService, DatabaseService
from mcserver.models_auto import Exercise
from mocks import Mocks
from openapi.openapi_server.models import H5PForm, ExerciseAuthor
from openapi.openapi_server.models.base_model_ import Model
......@@ -27,9 +29,12 @@ def determine_language(lang: str) -> Language:
def get(eid: str, lang: str, solution_indices: List[int]) -> Union[Response, ConnexionResponse]:
""" The GET method for the H5P REST API. It provides JSON templates for client-side H5P exercise layouts. """
if eid.endswith(f".{FileType.H5P}"):
return get_remote_exercise(eid)
language: Language = determine_language(lang)
exercise: Exercise = DatabaseService.query(Exercise, filter_by=dict(eid=eid), first=True)
if eid == Config.EXERCISE_ID_TEST:
from mocks import Mocks # use local import to avoid incorrect import order
exercise = Mocks.exercise
if not exercise:
return connexion.problem(404, Config.ERROR_TITLE_NOT_FOUND, Config.ERROR_MESSAGE_EXERCISE_NOT_FOUND)
......@@ -38,8 +43,7 @@ def get(eid: str, lang: str, solution_indices: List[int]) -> Union[Response, Con
return connexion.problem(
422, Config.ERROR_TITLE_UNPROCESSABLE_ENTITY, Config.ERROR_MESSAGE_UNPROCESSABLE_ENTITY)
response_dict: dict = TextService.json_template_mark_words
response_dict = get_response(response_dict, language, TextService.json_template_drag_text, exercise,
text_field_content, TextService.feedback_template)
response_dict = localize_exercise(response_dict, language, exercise, text_field_content)
return NetworkService.make_json_response(response_dict)
......@@ -48,9 +52,31 @@ def get_possible_enum_values(openapi_enum: Union[Model, Any]) -> Set[str]:
return set([x for x in list(dict(openapi_enum.__dict__).values())[2:] if type(x) == str])
def get_response(response_dict: dict, lang: Language, json_template_drag_text: dict, exercise: Exercise,
text_field_content: str, feedback_template: str) -> dict:
"""Performs localization for an existing H5P exercise template and insert the relevant exercise materials."""
def get_remote_exercise(uri: str) -> Union[Response, ConnexionResponse]:
""" Retrieves an H5P archive from a remote location and builds a JSON template for it."""
response: requests.Response = requests.get(uri)
zipinmemory = BytesIO(response.content)
zip_file: ZipFile = ZipFile(zipinmemory, "r")
lib_dict: dict = json.load(zip_file.open("h5p.json"))
h5p_dict: dict = json.load(zip_file.open("content/content.json"))
h5p_dict["mainLibrary"] = lib_dict["mainLibrary"]
return NetworkService.make_json_response(h5p_dict)
def get_text_field_content(exercise: Exercise, solution_indices: List[int]) -> str:
"""Builds the text field content for a H5P exercise, i.e. the task, exercise material and solutions."""
text_field_content: str = ""
if exercise.exercise_type in [ExerciseType.cloze.value, ExerciseType.markWords.value]:
text_field_content = TextService.get_h5p_text_with_solutions(exercise, solution_indices)
elif exercise.exercise_type == ExerciseType.matching.value:
solutions: List[Solution] = TextService.get_solutions_by_index(exercise, solution_indices)
for solution in solutions:
text_field_content += "{0} *{1}*\n".format(solution.target.content, solution.value.content)
return text_field_content
def localize_exercise(response_dict: dict, lang: Language, exercise: Exercise, text_field_content: str) -> dict:
"""Performs localization for an existing H5P exercise template and inserts the relevant exercise materials."""
# default values for buttons and response
button_dict: dict = {"check": ["checkAnswerButton", "Prüfen" if lang == Language.German else "Check"],
"again": ["tryAgainButton", "Nochmal" if lang == Language.German else "Retry"],
......@@ -59,31 +85,19 @@ def get_response(response_dict: dict, lang: Language, json_template_drag_text: d
button_dict["check"][0] = "checkAnswer"
button_dict["again"][0] = "tryAgain"
button_dict["solution"][0] = "showSolution"
response_dict = json_template_drag_text
response_dict = TextService.json_template_drag_text
for button in button_dict:
response_dict[button_dict[button][0]] = button_dict[button][1]
response_dict["taskDescription"] = "<p>{0}: {1}</p>\n".format(exercise.exercise_type_translation,
exercise.instructions)
response_dict["textField"] = text_field_content
feedback: str = feedback_template.format("Punkte", "von")
feedback: str = TextService.feedback_template.format("Punkte", "von")
if lang != Language.German:
feedback = feedback_template.format("Score", "of")
feedback = TextService.feedback_template.format("Score", "of")
response_dict["overallFeedback"][0]["feedback"] = feedback
return response_dict
def get_text_field_content(exercise: Exercise, solution_indices: List[int]) -> str:
"""Builds the text field content for a H5P exercise, i.e. the task, exercise material and solutions."""
text_field_content: str = ""
if exercise.exercise_type in [ExerciseType.cloze.value, ExerciseType.markWords.value]:
text_field_content = TextService.get_h5p_text_with_solutions(exercise, solution_indices)
elif exercise.exercise_type == ExerciseType.matching.value:
solutions: List[Solution] = TextService.get_solutions_by_index(exercise, solution_indices)
for solution in solutions:
text_field_content += "{0} *{1}*\n".format(solution.target.content, solution.value.content)
return text_field_content
def make_h5p_archive(file_name_no_ext: str, response_dict: dict, target_dir: str, file_name: str):
"""Creates a H5P archive (in ZIP format) for a given exercise."""
source_dir: str = os.path.join(Config.H5P_DIRECTORY, file_name_no_ext)
......@@ -95,7 +109,7 @@ def make_h5p_archive(file_name_no_ext: str, response_dict: dict, target_dir: str
white_list: Set[str] = {'.svg', '.otf', '.json', '.css', '.diff', '.woff', '.eot', '.png', '.gif',
'.woff2', '.js', '.ttf'}
excluded_folders: Set[str] = get_possible_enum_values(ExerciseAuthor).union({"content"})
with zipfile.ZipFile(os.path.join(target_dir, file_name), "w") as zipObj:
with ZipFile(os.path.join(target_dir, file_name), "w") as zipObj:
# Iterate over all the files in directory
for folder_name, subfolders, file_names in os.walk(source_dir):
if any(x for x in excluded_folders if folder_name.endswith(x)):
......@@ -122,8 +136,7 @@ def post(h5p_data: dict):
return connexion.problem(
422, Config.ERROR_TITLE_UNPROCESSABLE_ENTITY, Config.ERROR_MESSAGE_UNPROCESSABLE_ENTITY)
response_dict: dict = TextService.json_template_mark_words
response_dict = get_response(response_dict, language, TextService.json_template_drag_text, exercise,
text_field_content, TextService.feedback_template)
response_dict = localize_exercise(response_dict, language, exercise, text_field_content)
file_name_no_ext: str = str(h5p_form.exercise_type_path)
file_name: str = f"{file_name_no_ext}.{FileType.ZIP}"
target_dir: str = Config.TMP_DIRECTORY
......
"""The Zenodo API. Add it to your REST API to provide users with exercise materials from the Zenodo repository."""
from collections import Counter
from requests import Response
from sickle import Sickle
from typing import List, Set, Any, Dict
import inspect
......@@ -6,10 +9,10 @@ from sickle.models import Record
from mcserver import Config
from mcserver.app.services import NetworkService
from openapi.openapi_server.models import ZenodoRecord
from openapi.openapi_server.models import ZenodoRecord, ZenodoForm, ZenodoMetadataPrefix
def get():
def get() -> Response:
"""The GET method for the Zenodo REST API. It provides exercise materials from the Zenodo repository."""
record_members: List[tuple] = inspect.getmembers(ZenodoRecord, lambda a: not (inspect.isroutine(a)))
record_properties: Set[str] = set([x[0] for x in record_members if type(x[1]) == property])
......@@ -17,11 +20,14 @@ def get():
work_property: str = "Werk:"
version_property: str = "Version:"
sickle: Sickle = Sickle(Config.ZENODO_API_URL)
records: List[Record] = list(sickle.ListRecords(metadataPrefix='oai_dc', set=Config.ZENODO_SET))
# use oai_datacite because it is the recommended default metadata scheme for Zenodo
records: List[Record] = list(
sickle.ListRecords(metadataPrefix=ZenodoMetadataPrefix.OAI_DATACITE, set=Config.ZENODO_SET))
property_map: Dict[str, str] = {"creator_name": "creatorName"}
zenodo_records: List[ZenodoRecord] = []
for record in records:
md: dict = record.metadata
params: Dict[str, Any] = {rp: md.get(rp, []) for rp in record_properties}
params: Dict[str, Any] = {rp: md.get(property_map.get(rp, rp), []) for rp in record_properties}
new_record: ZenodoRecord = ZenodoRecord(**params)
tags: List[str] = md.get("subject", [])
new_record.author = [next((x.split(author_property)[-1] for x in tags if x.startswith(author_property)), "")]
......@@ -29,4 +35,28 @@ def get():
new_record.version = \
[next((x.split(version_property)[-1] for x in tags if x.startswith(version_property)), "1.0")]
zenodo_records.append(new_record)
remove_older_versions(zenodo_records)
return NetworkService.make_json_response([x.to_dict() for x in zenodo_records])
def post(zenodo_data: dict) -> Response:
"""The POST method for the Zenodo REST API. It provides file URIs for exercises from the Zenodo repository."""
zenodo_form: ZenodoForm = ZenodoForm.from_dict(zenodo_data)
sickle: Sickle = Sickle(Config.ZENODO_API_URL)
record: Record = sickle.GetRecord(metadataPrefix=ZenodoMetadataPrefix.MARC21,
identifier=f"oai:zenodo.org:{zenodo_form.record_id}")
uris: List[str] = [x for x in record.metadata['subfield'] if x.startswith("http")]
return NetworkService.make_json_response(uris)
def remove_older_versions(zenodo_records: List[ZenodoRecord]) -> None:
""" Removes older versions of a record from the list, if there are multiple versions of the same record. """
counter: Counter = Counter([x.title[0] for x in zenodo_records])
titles_to_delete: Set[str] = set([x for x in counter.keys() if counter[x] > 1])
for tdd in titles_to_delete:
relevant_records: List[ZenodoRecord] = [x for x in zenodo_records if x.title[0] == tdd]
versions: List[float] = [float(x.version[0]) for x in relevant_records]
max_version: float = max(versions)
relevant_records = [relevant_records[i] for i in range(len(versions)) if versions[i] < max_version]
for rr in relevant_records:
zenodo_records.remove(rr)
......@@ -157,7 +157,6 @@ class VocabularyCorpus(Enum):
agldt = Config.VOCABULARY_AGLDT_FILE_NAME
bws = Config.VOCABULARY_BWS_FILE_NAME
proiel = Config.VOCABULARY_PROIEL_FILE_NAME
vischer = Config.VOCABULARY_VISCHER_FILE_NAME
viva = Config.VOCABULARY_VIVA_FILE_NAME
......@@ -206,7 +205,7 @@ class ExerciseMC:
exercise_type_translation: str = "",
language: str = "de",
solutions: str = "[]",
text_complexity: float = 0,
text_complexity: float = 0.0,
urn: str = "",
) -> TExercise:
return Exercise.from_dict(
......@@ -355,13 +354,14 @@ class Score:
self.max: int = json_dict["max"]
self.min: int = json_dict["min"]
self.raw: int = json_dict["raw"]
self.scaled: float = json_dict["scaled"]
# explicit float conversion for the case when scaled is 0, i.e. integer
self.scaled: float = float(json_dict["scaled"])
class Result:
def __init__(self, json_dict: dict):
self.completion: bool = json_dict["completion"]
self.duration: str = json_dict["duration"]
self.duration: str = json_dict.get("duration", "PT0S")
self.response: str = json_dict["response"]
self.score: Score = Score(json_dict["score"])
self.success: bool = json_dict.get("success", self.score.raw == self.score.max)
......
......@@ -2,7 +2,7 @@ import sys
from datetime import datetime
import rapidjson as json
import os
from typing import List, Union, Set, Tuple, Dict
from typing import List, Union, Set, Dict
import requests
from MyCapytain.retrievers.cts5 import HttpCtsRetriever
from conllu import TokenList
......
......@@ -43,7 +43,7 @@ class DatabaseService:
ui_cts: UpdateInfo = DatabaseService.query(
UpdateInfo, filter_by=dict(resource_type=rt.name), first=True)
if ui_cts is None:
ui_cts = UpdateInfo.from_dict(resource_type=rt.name, last_modified_time=1,
ui_cts = UpdateInfo.from_dict(resource_type=rt.name, last_modified_time=1.0,
created_time=datetime.utcnow().timestamp())
db.session.add(ui_cts)
DatabaseService.commit()
......
......@@ -67,8 +67,8 @@ class Config(object):
DATABASE_TABLE_CORPUS = "Corpus"
DATABASE_TABLE_EXERCISE = "Exercise"
DATABASE_TABLE_UPDATEINFO = "UpdateInfo"
DATABASE_URL_DOCKER = "postgresql://postgres@db:5432/"
DATABASE_URL_LOCAL = "postgresql://postgres@0.0.0.0:5432/postgres"
DATABASE_URL_DOCKER = "postgresql://postgres@db:5432/callidus"
DATABASE_URL_LOCAL = "postgresql://postgres@0.0.0.0:5432/callidus"
DATABASE_URL_SQLITE = f"sqlite:///{os.path.join(basedir, 'app.db')}"
DATABASE_URL_SQLITE_MEMORY = "sqlite:///:memory:"
DATABASE_URL_FALLBACK = DATABASE_URL_DOCKER if IS_DOCKER else DATABASE_URL_SQLITE
......@@ -161,7 +161,6 @@ class Config(object):
VOCABULARY_BWS_FILE_NAME = "vocabulary_bamberg_core.json"
VOCABULARY_DIRECTORY = os.path.join(ASSETS_DIRECTORY, "vocabulary")
VOCABULARY_PROIEL_FILE_NAME = "vocabulary_proiel_treebank.json"
VOCABULARY_VISCHER_FILE_NAME = "vocabulary_vischer.json"
VOCABULARY_VIVA_FILE_NAME = "vocabulary_viva.json"
ZENODO_API_URL = "https://zenodo.org/oai2d"
ZENODO_SET = "user-lexiprojekt-latin-unipotsdam"
......
"""Configuration for the gunicorn server"""
import multiprocessing
from mcserver import Config
from mcserver import Config, get_cfg
bind = "{0}:{1}".format(Config.HOST_IP_MCSERVER, Config.HOST_PORT)
debug = False
bind = f"{Config.HOST_IP_MCSERVER}:{Config.HOST_PORT}"
debug = not get_cfg().IS_PRODUCTION
reload = True
timeout = 3600
workers = multiprocessing.cpu_count() * 2 + 1
......@@ -454,8 +454,29 @@ paths:
type: array
items:
$ref: '../openapi_models.yaml#/components/schemas/ZenodoRecord'
post:
summary: Shows which exercise files are available for download in a specific record on Zenodo.
operationId: mcserver.app.api.zenodoAPI.post
responses:
"200":
description: Lists URIs for exercise files that can be downloaded.
content:
application/json:
schema:
type: array
items:
type: string
example: https://zenodo.org/record/4548959/files/Val_Fl_IbisV_Wortschatzuebung_Meeresbilder.docx
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
$ref: '../openapi_models.yaml#/components/schemas/ZenodoForm'
# include this here so the data model gets generated correctly
components:
schemas:
ExerciseAuthorExtension:
$ref: '../openapi_models.yaml#/components/schemas/ExerciseAuthor'
ZenodoMetadataPrefixExtension:
$ref: '../openapi_models.yaml#/components/schemas/ZenodoMetadataPrefix'
......@@ -8,6 +8,8 @@ from sqlalchemy import orm
from open_alchemy import models
Base = models.Base # type: ignore
class _CorpusDictBase(typing.TypedDict, total=True):
"""TypedDict for properties that are required."""
......@@ -49,24 +51,15 @@ class TCorpus(typing.Protocol):
query: orm.Query
# Model properties
author: str
cid: int
citation_level_1: str
citation_level_2: str
citation_level_3: str
source_urn: str
title: str
author: 'sqlalchemy.Column[str]'
cid: 'sqlalchemy.Column[int]'
citation_level_1: 'sqlalchemy.Column[str]'
citation_level_2: 'sqlalchemy.Column[str]'
citation_level_3: 'sqlalchemy.Column[str]'
source_urn: 'sqlalchemy.Column[str]'
title: 'sqlalchemy.Column[str]'
def __init__(
self,
source_urn: str,
author: str = "Anonymus",
cid: typing.Optional[int] = None,
citation_level_1: str = "default",
citation_level_2: str = "default",
citation_level_3: str = "default",
title: str = "Anonymus",
) -> None:
def __init__(self, source_urn: str, author: str = "Anonymus", cid: typing.Optional[int] = None, citation_level_1: str = "default", citation_level_2: str = "default", citation_level_3: str = "default", title: str = "Anonymus") -> None:
"""
Construct.
......@@ -83,16 +76,7 @@ class TCorpus(typing.Protocol):
...
@classmethod
def from_dict(
cls,
source_urn: str,
author: str = "Anonymus",
cid: typing.Optional[int] = None,
citation_level_1: str = "default",
citation_level_2: str = "default",
citation_level_3: str = "default",
title: str = "Anonymus",
) -> "TCorpus":
def from_dict(cls, source_urn: str, author: str = "Anonymus", cid: typing.Optional[int] = None, citation_level_1: str = "default", citation_level_2: str = "default", citation_level_3: str = "default", title: str = "Anonymus") -> "TCorpus":
"""
Construct from a dictionary (eg. a POST payload).
......@@ -143,7 +127,7 @@ class TCorpus(typing.Protocol):
...
Corpus: TCorpus = models.Corpus # type: ignore
Corpus: typing.Type[TCorpus] = models.Corpus # type: ignore
class _ExerciseDictBase(typing.TypedDict, total=True):
......@@ -158,6 +142,7 @@ class _ExerciseDictBase(typing.TypedDict, total=True):
class ExerciseDict(_ExerciseDictBase, total=False):
"""TypedDict for properties that are not required."""
exercise_type_translation: str
correct_feedback: str
general_feedback: str
incorrect_feedback: str
......@@ -170,7 +155,6 @@ class ExerciseDict(_ExerciseDictBase, total=False):
solutions: str
text_complexity: float
urn: str
exercise_type_translation: str
class TExercise(typing.Protocol):
......@@ -180,6 +164,7 @@ class TExercise(typing.Protocol):
Data for creating and evaluating interactive exercises.
Attrs:
exercise_type_translation: Localized expression of the exercise type.
correct_feedback: Feedback for successful completion of the exercise.
general_feedback: Feedback for finishing the exercise.
incorrect_feedback: Feedback for failing to complete the exercise
......@@ -203,7 +188,6 @@ class TExercise(typing.Protocol):
text_complexity: Overall text complexity as measured by the software's
internal language analysis.
urn: CTS URN for the text passage from which the exercise was created.
exercise_type_translation: Localized expression of the exercise type.
"""
......@@ -213,48 +197,31 @@ class TExercise(typing.Protocol):
query: orm.Query
# Model properties
correct_feedback: str
general_feedback: str
incorrect_feedback: str
instructions: str
language: str
partially_correct_feedback: str
search_values: str
work_author: str
work_title: str
conll: str
eid: str
exercise_type: str
last_access_time: float
solutions: str
text_complexity: float
urn: str
exercise_type_translation: str
def __init__(
self,
instructions: str,
search_values: str,
eid: str,
last_access_time: float,
correct_feedback: str = "",
general_feedback: str = "",
incorrect_feedback: str = "",
language: str = "de",
partially_correct_feedback: str = "",
work_author: str = "",
work_title: str = "",
conll: str = "",
exercise_type: str = "",
solutions: str = "[]",
text_complexity: float = 0,
urn: str = "",
exercise_type_translation: str = "",
) -> None:
exercise_type_translation: 'sqlalchemy.Column[str]'
correct_feedback: 'sqlalchemy.Column[str]'
general_feedback: 'sqlalchemy.Column[str]'
incorrect_feedback: 'sqlalchemy.Column[str]'
instructions: 'sqlalchemy.Column[str]'
language: 'sqlalchemy.Column[str]'
partially_correct_feedback: 'sqlalchemy.Column[str]'
search_values: 'sqlalchemy.Column[str]'
work_author: 'sqlalchemy.Column[str]'
work_title: 'sqlalchemy.Column[str]'
conll: 'sqlalchemy.Column[str]'
eid: 'sqlalchemy.Column[str]'
exercise_type: 'sqlalchemy.Column[str]'
last_access_time: 'sqlalchemy.Column[float]'
solutions: 'sqlalchemy.Column[str]'
text_complexity: 'sqlalchemy.Column[float]'
urn: 'sqlalchemy.Column[str]'
def __init__(self, instructions: str, search_values: str, eid: str, last_access_time: float, exercise_type_translation: str = "", correct_feedback: str = "", general_feedback: str = "", incorrect_feedback: str = "", language: str = "de", partially_correct_feedback: str = "", work_author: str = "", work_title: str = "", conll: str = "", exercise_type: str = "", solutions: str = "[]", text_complexity: float = 0.0, urn: str = "") -> None:
"""
Construct.
Args:
exercise_type_translation: Localized expression of the exercise
type.
correct_feedback: Feedback for successful completion of the
exercise.
general_feedback: Feedback for finishing the exercise.
......@@ -280,37 +247,18 @@ class TExercise(typing.Protocol):
software's internal language analysis.
urn: CTS URN for the text passage from which the exercise was
created.
exercise_type_translation: Localized expression of the exercise
type.
"""
...
@classmethod
def from_dict(
cls,
instructions: str,
search_values: str,
eid: str,
last_access_time: float,
correct_feedback: str = "",
general_feedback: str = "",
incorrect_feedback: str = "",
language: str = "de",
partially_correct_feedback: str = "",
work_author: str = "",
work_title: str = "",