Commit dde8a912 authored by Jonas Waeber's avatar Jonas Waeber
Browse files

Update record set & institution index generation & tests.

parent 0e5460f2
Pipeline #21852 passed with stages
in 5 minutes and 17 seconds
......@@ -34,7 +34,7 @@ ext {
dependencies {
compile group: 'org.elasticsearch.client', name: 'elasticsearch-rest-high-level-client', version: '7.6.1'
implementation 'org.memobase:memobase-service-utilities:2.0.9'
implementation 'org.memobase:memobase-service-utilities:2.0.13'
implementation 'org.apache.jena:apache-jena:3.14.0'
// Logging Framework
......
......@@ -7,6 +7,7 @@ data:
APPLICATION_ID: "{{ .Values.deploymentName }}-app"
DOCUMENTS_INDEX: "{{ .Values.documentsIndex }}"
INSTITUTION_INDEX: "{{ .Values.institutionIndex }}"
RECORD_SET_INDEX: "{{ .Values.recordSetIndex }}"
MEDIA_SERVER_URL: "{{ .Values.mediaServerUrl }}"
TOPIC_IN: "{{ .Values.inputTopic }}"
TOPIC_OUT: "{{ .Values.outputTopic }}"
......
......@@ -9,6 +9,7 @@ elasticConfigs: prod-elastic-configs
documentsIndex: documents-v17
institutionIndex: institutions-v1
recordSetIndex: record-sets-v1
outputTopic: search-doc-output-documents
inputTopic: search-doc-input-documents
reportingTopic: postprocessing-reporting
......
......@@ -24,7 +24,7 @@ import org.memobase.helpers.KEYS.SettingsProps
class App {
companion object {
private val log = LogManager.getLogger("App")
private val log = LogManager.getLogger("SearchDocServiceApp")
fun createSettings(file: String): SettingsLoader {
return SettingsLoader(
listOf(
......@@ -36,7 +36,8 @@ class App {
SettingsProps.elasticHost,
SettingsProps.elasticPort,
SettingsProps.documentsIndex,
SettingsProps.institutionIndex
SettingsProps.institutionIndex,
SettingsProps.recordSetIndex
),
file,
useStreamsConfig = true
......
......@@ -19,9 +19,10 @@
package org.memobase
import ch.memobase.rdf.DC
import ch.memobase.rdf.NS
import ch.memobase.rdf.RDA
import ch.memobase.rdf.RDF
import ch.memobase.rdf.RICO
import ch.memobase.rdf.RICO.Types.RecordSet
import com.beust.klaxon.JsonObject
import org.apache.logging.log4j.LogManager
import org.memobase.helpers.Date
......@@ -42,8 +43,8 @@ class RecordSetSearchDocBuilder(private val elasticSearchWrapper: ElasticSearchW
fun transform(key: String, input: Map<String, JsonObject>): Schema {
val recordSet =
input[JSON.recordSetTag] ?: throw InvalidInputException("No record set entity found in message $key.")
val publicationIds = Extract.identifiers(recordSet[RICO.isSubjectOf.localName])
val relatedRecordSetIds = Extract.identifiers(recordSet[RICO.isRecordResourceAssociatedWithRecordResource.localName])
val relatedRecordSetIds =
Extract.identifiers(recordSet[RICO.isRecordResourceAssociatedWithRecordResource.localName])
val metadataLanguages = mutableListOf<JsonObject>()
var originalTitles = LanguageContainer.EMPTY
var projectTitles = LanguageContainer.EMPTY
......@@ -63,19 +64,25 @@ class RecordSetSearchDocBuilder(private val elasticSearchWrapper: ElasticSearchW
it[RICO.type.localName] == KEYS.CorporateBodyType.memoriavProject -> {
projectTitles = projectTitles.add(it[RICO.title.localName])
}
it[KEYS.atType] == RICO.RecordSet.uri &&
relatedRecordSetIds.contains(it[KEYS.entityId]) -> {
it[KEYS.atType] == RICO.RecordSet.uri && it[RICO.type.localName] == RecordSet.related -> {
relatedRecordSets = relatedRecordSets.add(it[RICO.title.localName])
}
it[KEYS.atType] == RICO.Record.uri -> {
if (publicationIds.contains(it[KEYS.entityId])) {
if (it[RICO.type.localName] == RICO.Types.Record.publication) {
publicationTitles = publicationTitles.add(it[RICO.title.localName])
} else {
} else if (it[RICO.type.localName] == RICO.Types.Record.related) {
relatedDocumentTitles = relatedDocumentTitles.add(it[RICO.title.localName])
}
}
}
}
// related record sets which are present in memobase.
relatedRecordSetIds.forEach { id ->
if (id.startsWith(NS.mbrs)) {
val languageContainer = elasticSearchWrapper.getRecordSetName(id.substringAfterLast("/"))
relatedRecordSets = relatedRecordSets.merge(languageContainer)
}
}
val name = extractLanguageContainer(recordSet[RICO.title.localName], "")
val dates = Extract.identifiers(recordSet[RICO.isAssociatedWithDate.localName]).mapNotNull {
......
......@@ -40,8 +40,7 @@ class Service(settings: SettingsLoader) {
const val name = "search-doc-service"
}
private val log = LogManager.getLogger("SearchDocService")
private val log = LogManager.getLogger("SearchDocServiceService")
private val appSettings = settings.appSettings
private val documentMapperPath = appSettings.getProperty(SettingsProps.documentTypeLabelsPath)
......@@ -55,6 +54,7 @@ class Service(settings: SettingsLoader) {
private val port = appSettings.getProperty(SettingsProps.elasticPort).toInt()
private val documentsIndex = appSettings.getProperty(SettingsProps.documentsIndex)
private val institutionIndex = appSettings.getProperty(SettingsProps.institutionIndex)
private val recordSetIndex = appSettings.getProperty(SettingsProps.recordSetIndex)
private val client: RestHighLevelClient = connect()
private fun connect(): RestHighLevelClient {
......@@ -67,13 +67,20 @@ class Service(settings: SettingsLoader) {
val indexExists = c.indices().exists(GetIndexRequest(documentsIndex), RequestOptions.DEFAULT)
val aliasExists = c.indices().existsAlias(GetAliasesRequest(documentsIndex), RequestOptions.DEFAULT)
val institutionIndexExists = c.indices().exists(GetIndexRequest(institutionIndex), RequestOptions.DEFAULT)
val institutionIndexAliasExists = c.indices().existsAlias(GetAliasesRequest(institutionIndex), RequestOptions.DEFAULT)
val institutionIndexAliasExists =
c.indices().existsAlias(GetAliasesRequest(institutionIndex), RequestOptions.DEFAULT)
val recordSetIndexExists = c.indices().exists(GetIndexRequest(recordSetIndex), RequestOptions.DEFAULT)
val recordSetIndexAliasExists =
c.indices().existsAlias(GetAliasesRequest(recordSetIndex), RequestOptions.DEFAULT)
if (!indexExists && !aliasExists && !institutionIndexExists && !institutionIndexAliasExists) {
log.error("Could not find the indices or aliases defined in the configuration: $documentsIndex, $institutionIndex.")
if (!indexExists && !aliasExists && !institutionIndexExists && !institutionIndexAliasExists
&& !recordSetIndexExists && !recordSetIndexAliasExists
) {
log.error("Could not find at least one index name or alias defined " +
"in the configuration: $documentsIndex, $institutionIndex, $recordSetIndex.")
exitProcess(1)
} else {
log.info("Successfully connected to indices $documentsIndex and $institutionIndex. Ready to query.")
log.info("Connected to $documentsIndex, $institutionIndex, $recordSetIndex. Ready to query.")
c
}
} catch (ex: ElasticsearchException) {
......@@ -88,7 +95,8 @@ class Service(settings: SettingsLoader) {
}
}
private val elasticSearchWrapper = ElasticSearchWrapper(appSettings, client, translationMappers)
private val elasticSearchWrapper =
ElasticSearchWrapper(client, translationMappers, documentsIndex, institutionIndex, recordSetIndex)
private val topology = KafkaTopology(settings, translationMappers, elasticSearchWrapper).build()
private val stream = KafkaStreams(topology, settings.kafkaStreamsSettings)
......
......@@ -19,7 +19,6 @@ package org.memobase.helpers
import com.beust.klaxon.Klaxon
import com.beust.klaxon.KlaxonException
import java.util.Properties
import org.apache.logging.log4j.LogManager
import org.elasticsearch.ElasticsearchException
import org.elasticsearch.action.get.GetRequest
......@@ -35,7 +34,6 @@ import org.elasticsearch.search.Scroll
import org.elasticsearch.search.builder.SearchSourceBuilder
import org.memobase.model.FacetContainer
import org.memobase.model.LanguageContainer
import org.memobase.model.LanguageContainer.Companion
/**
......@@ -43,18 +41,15 @@ import org.memobase.model.LanguageContainer.Companion
* the necessary data.
*/
class ElasticSearchWrapper(
settings: Properties,
private val client: RestHighLevelClient,
private val translationMappers: TranslationMappers
private val translationMappers: TranslationMappers,
private val documentsIndex: String,
private val institutionIndex: String,
private val recordSetIndex: String
) {
private val log = LogManager.getLogger("ElasticSearchWrapper")
private val documentsIndex = settings.getProperty(KEYS.SettingsProps.documentsIndex)
private val institutionIndex = settings.getProperty(KEYS.SettingsProps.institutionIndex)
private val klaxon = Klaxon()
/**
* Counts the number of documents attached to a specific record set.
*
......@@ -181,4 +176,23 @@ class ElasticSearchWrapper(
}
fun getRecordSetName(identifier: String): LanguageContainer {
return try {
log.info("Attempting to retrieve record set document from $recordSetIndex.")
val request = GetRequest(recordSetIndex, identifier)
val response = client.get(request, RequestOptions.DEFAULT)
if (response.isExists) {
val map = response.sourceAsMap.getValue("name")
log.info("Successfully retrieved record set names: $map.")
LanguageContainer.fromMap(map)
} else {
log.error("Could not find record set $identifier in index $recordSetIndex.")
LanguageContainer.EMPTY
}
} catch (ex: ElasticsearchException) {
log.error(ex.detailedMessage)
LanguageContainer.EMPTY
}
}
}
\ No newline at end of file
......@@ -17,8 +17,8 @@
*/
package org.memobase.helpers
import ch.memobase.rdf.NS
import ch.memobase.rdf.RICO
import ch.memobase.rdf.RICO.Types
import com.beust.klaxon.JsonArray
import com.beust.klaxon.JsonObject
import com.beust.klaxon.Klaxon
......@@ -52,9 +52,15 @@ object JSON {
fun unpack(input: JsonObject): Map<String, JsonObject> {
val graph = input[graph] as JsonArray<JsonObject>
return graph.map {
if (it[KEYS.atType] == RICO.Record.uri) {
if (it[KEYS.atType] == RICO.Record.uri && it[RICO.type.localName] !in listOf(
Types.Record.publication,
Types.Record.related
)
) {
Pair(recordTag, it)
} else if (it[KEYS.atType] == RICO.RecordSet.uri) {
} else if (it[KEYS.atType] == RICO.RecordSet.uri &&
it[RICO.type.localName] !in listOf(Types.RecordSet.original, Types.RecordSet.related)
) {
Pair(recordSetTag, it)
} else if (it[KEYS.atType] == RICO.CorporateBody.uri && it[KEYS.ricoType] == KEYS.CorporateBodyType.memobaseInstitution) {
Pair(institutionTag, it)
......
......@@ -33,6 +33,7 @@ object KEYS {
const val elasticPort = "elastic.port"
const val documentsIndex = "elastic.documentsIndex"
const val institutionIndex = "elastic.institutionIndex"
const val recordSetIndex = "elastic.recordSetIndex"
}
......
......@@ -4,6 +4,7 @@ app:
port: ${ELASTIC_PORT:?system}
documentsIndex: ${DOCUMENTS_INDEX:?system}
institutionIndex: ${INSTITUTION_INDEX:?system}
recordSetIndex: ${RECORD_SET_INDEX:?system}
media:
url: ${MEDIA_SERVER_URL:?system}
institutionTypeLabelsPath: "/configs/institution_types/labels.csv"
......
......@@ -2,7 +2,6 @@ package org.memobase
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.util.Properties
import kotlin.system.exitProcess
import org.apache.http.HttpHost
import org.apache.logging.log4j.LogManager
......@@ -18,7 +17,6 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.assertAll
import org.memobase.helpers.ElasticSearchWrapper
import org.memobase.helpers.KEYS
import org.memobase.model.FacetContainer
import org.memobase.model.LanguageContainer
......@@ -30,6 +28,7 @@ class TestElasticSearchWrapper {
private val port = 8080
private val documentsIndex = "documents-v17"
private val institutionIndex = "institutions-v1"
private val recordSetIndex = "record-sets-v1"
private val client: RestHighLevelClient = connect()
......@@ -66,12 +65,13 @@ class TestElasticSearchWrapper {
@Test
@Disabled
fun `test get institution name`() {
val props = Properties()
props.setProperty(KEYS.SettingsProps.documentsIndex, documentsIndex)
props.setProperty(KEYS.SettingsProps.institutionIndex, institutionIndex)
val wrapper = ElasticSearchWrapper(props, client, TestUtilities.translationMappers)
val wrapper = ElasticSearchWrapper(
client,
TestUtilities.translationMappers,
documentsIndex,
institutionIndex,
recordSetIndex
)
val result = wrapper.getInstitutionName("aag")
assertAll("",
{
......@@ -100,12 +100,13 @@ class TestElasticSearchWrapper {
@Test
@Disabled
fun `test getDocumentTypesFromRecords`() {
val props = Properties()
props.setProperty(KEYS.SettingsProps.documentsIndex, documentsIndex)
props.setProperty(KEYS.SettingsProps.institutionIndex, institutionIndex)
val wrapper = ElasticSearchWrapper(props, client, TestUtilities.translationMappers)
val wrapper = ElasticSearchWrapper(
client,
TestUtilities.translationMappers,
documentsIndex,
institutionIndex,
recordSetIndex
)
val results = wrapper.getDocumentTypesFromRecords("aag-001", "recordSet.facet")
assertAll("",
{
......
......@@ -18,6 +18,7 @@ import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.assertAll
import org.memobase.helpers.ElasticSearchWrapper
import org.memobase.helpers.JSON
import org.memobase.helpers.KEYS.SettingsProps
import org.memobase.model.FacetContainer
import org.memobase.model.InstitutionSearchDoc
import org.memobase.model.LanguageContainer
......@@ -25,7 +26,6 @@ import org.memobase.model.LanguageContainer
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class TestInstitutionSearchDoc {
private val reader = ObjectMapper().registerKotlinModule().reader()
private val dataPath = "src/test/resources/data/institution"
private fun readFile(fileName: String): String {
return File("$dataPath/$fileName").readText(Charset.defaultCharset())
......@@ -36,9 +36,11 @@ class TestInstitutionSearchDoc {
fun `test institution search doc with production es client`() {
val props = App.createSettings("kafkaTest1.yml")
val elastic = ElasticSearchWrapper(
props.appSettings,
TestUtilities.connectToElasticSearch("localhost", 8080, "documents-v17"),
TestUtilities.translationMappers
TestUtilities.connectToElasticSearch("localhost", 8080, TestUtilities.currentDocumentsIndex),
TestUtilities.translationMappers,
props.appSettings.getProperty(SettingsProps.documentsIndex),
props.appSettings.getProperty(SettingsProps.institutionIndex),
props.appSettings.getProperty(SettingsProps.recordSetIndex)
)
val input = JSON.unpack(JSON.parse(readFile("completeExample.json")))
......@@ -109,7 +111,7 @@ class TestInstitutionSearchDoc {
val key = record.key()
val value = record.value().replace(TestUtilities.dateRegex, "2020")
val resultValue = readFile("completeExample.json").replace(TestUtilities.dateRegex, "2020")
val resultValue = readFile("completeExampleOutput.json").replace(TestUtilities.dateRegex, "2020")
assertAll("",
{
......
......@@ -37,7 +37,7 @@ class TestRecordSetSearchDoc {
fun `test create default record set `() {
val searchDoc = RecordSetSearchDoc.DEFAULT
assertThat(searchDoc.toJson())
.isEqualTo(readFile("default_record_set.json"))
.isEqualTo(readFile("default_record_set.json"))
}
......@@ -48,7 +48,13 @@ class TestRecordSetSearchDoc {
val wrapper = mockk<ElasticSearchWrapper>()
every { wrapper.countNumberOfDocuments("testComplete") } returns 102
every { wrapper.getDocumentTypesFromRecords("testComplete", "recordSet.facet") } returns listOf(FacetContainer(LanguageContainer(listOf("Fotographie"), listOf("Photographie"), listOf("Fotografia"), emptyList()), null, emptyList()))
every { wrapper.getDocumentTypesFromRecords("testComplete", "recordSet.facet") } returns listOf(
FacetContainer(
LanguageContainer(listOf("Fotographie"), listOf("Photographie"), listOf("Fotografia"), emptyList()),
null,
emptyList()
)
)
every { wrapper.getInstitutionName("completeInstitution") } returns FacetContainer(
LanguageContainer(
listOf("Test Complete"),
......@@ -59,6 +65,13 @@ class TestRecordSetSearchDoc {
"completeExampleTest",
emptyList()
)
every {
wrapper.getRecordSetName("testComplete")
} returns LanguageContainer(
listOf("Complete Record Set (DE)"),
listOf("Complete Record Set (FR)"),
listOf("Complete Record Set (IT)")
)
val input = JSON.unpack(JSON.parse(data))
val searchDocBuilder = RecordSetSearchDocBuilder(wrapper)
......@@ -69,14 +82,14 @@ class TestRecordSetSearchDoc {
val targetString = readFile("completeExampleOutput.json").replace(TestUtilities.dateRegex, "2020")
assertAll("",
{
assertThat(result.id).isEqualTo("testComplete")
},
{
assertThat(resultString).isEqualTo(
targetString
)
}
{
assertThat(result.id).isEqualTo("testComplete")
},
{
assertThat(resultString).isEqualTo(
targetString
)
}
)
}
......@@ -96,26 +109,26 @@ class TestRecordSetSearchDoc {
KafkaTopology(settings, TestUtilities.translationMappers, TestUtilities.elasticSearchWrapperMocked)
val testDriver = TopologyTestDriver(topology.build(), settings.kafkaStreamsSettings)
val factory = ConsumerRecordFactory(
StringSerializer(), StringSerializer()
StringSerializer(), StringSerializer()
)
testDriver.pipeInput(
factory.create(
settings.inputTopic,
"testComplete",
readFile("completeExample.json")
)
factory.create(
settings.inputTopic,
"testComplete",
readFile("completeExample.json")
)
)
val record = testDriver.readOutput(
settings.outputTopic,
StringDeserializer(),
StringDeserializer()
settings.outputTopic,
StringDeserializer(),
StringDeserializer()
)
val report = testDriver.readOutput(
settings.processReportTopic,
StringDeserializer(),
StringDeserializer()
settings.processReportTopic,
StringDeserializer(),
StringDeserializer()
)
val reportKey = report.key()
......@@ -126,22 +139,22 @@ class TestRecordSetSearchDoc {
val resultValue = readFile("completeExampleOutput.json").replace(TestUtilities.dateRegex, "2020")
assertAll("",
{
assertThat(value)
.isEqualTo(resultValue)
},
{ assertThat(key).isEqualTo("testComplete") },
{ assertThat(reportKey).isEqualTo("testComplete") },
{
assertThat(reportValue).isEqualTo(
Report(
"testComplete",
ReportStatus.success,
"",
Service.name
)
{
assertThat(value)
.isEqualTo(resultValue)
},
{ assertThat(key).isEqualTo("testComplete") },
{ assertThat(reportKey).isEqualTo("testComplete") },
{
assertThat(reportValue).isEqualTo(
Report(
"testComplete",
ReportStatus.success,
"",
Service.name
)
}
)
}
)
}
}
\ No newline at end of file
......@@ -21,6 +21,7 @@ object TestUtilities {
private const val accessTermPath = "src/test/resources/configs/access-term-labels.csv"
private const val documentTypePath = "src/test/resources/configs/document-type-labels.csv"
private const val reuseStatementPath = "src/test/resources/configs/reuse-statement-labels.csv"
const val currentDocumentsIndex = "documents-v17"
val dateRegex = Regex("(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{2,3})")
......
......@@ -3,7 +3,7 @@
{
"@id": "_:b0",
"@type": "https://www.ica.org/standards/RiC/ontology#Identifier",
"identifier": "completeExampleTest",
"identifier": "completeInstitution",
"type": "main"
},
{
......@@ -16,8 +16,8 @@
"@id": "_:b2",
"@type": "https://www.ica.org/standards/RiC/ontology#Place",
"P131": [
"_:b4",
"_:b3"
"_:b3",
"_:b4"
],
"P17": "http://www.wikidata.org/entity/Q39",
"P281": "1000",
......@@ -29,12 +29,6 @@
{
"@id": "_:b3",
"@type": "https://www.ica.org/standards/RiC/ontology#Place",
"name": "City",
"type": "municipality"
},
{
"@id": "_:b4",
"@type": "https://www.ica.org/standards/RiC/ontology#Place",
"sameAs": "http://www.wikidata.org/entity/Q11972",
"name": [
{
......@@ -53,65 +47,66 @@
"type": "canton"
},
{
"@id": "https://memobase.ch/institution/completeExampleTest",
"@id": "_:b4",
"@type": "https://www.ica.org/standards/RiC/ontology#Place",
"name": "City",
"type": "municipality"
},
{
"@id": "https://memobase.ch/institution/completeInstitution",
"@type": "https://www.ica.org/standards/RiC/ontology#CorporateBody",
"P18": "https://mb-wf1.memobase.unibas.ch/sites/default/files/styles/teaser/public/2021-02/vitrine1_hero.jpg?itok=S-b5nq1p",
"P2699": "https://archive-online.com",
"P31": "http://www.wikidata.org/entity/Q2029941",
"P31": [
"http://www.wikidata.org/entity/Q15265344",
"http://www.wikidata.org/entity/Q2029941",
"http://www.wikidata.org/entity/Q591763"
],
"P791": "ISIL-NUMBER",
"P856": "https://website.com",
"P968": "test@email.com",
"P968": "test-institution@email.com",
"eventType": "CREATE",
"isPublished": false,
"descriptiveNote": [
{
"@language": "de",
"@value": "<p>Beschreibung (DE)</p>\r\n\r\n<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>"
"@language": "fr",
"@value": "<p>Beschreibung (FR)</p>\r\n\r\n<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>"
},
{
"@language": "it",
"@value": "<p>Beschreibung (IT)</p>\r\n\r\n<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>"
},
{
"@language": "fr",
"@value": "<p>Beschreibung (FR)</p>\r\n\r\n<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>"
"@language": "de",
"@value": "<p>Beschreibung (DE)</p>\r\n\r\n<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>"
}
],
"hasLocation": "_:b2",
"identifiedBy": [
"_:b1",
"_:b0"
"_:b0",