diff --git a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaBookPayloadReader.kt b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaBookPayloadReader.kt index 3e7c4d78..42dd9a48 100644 --- a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaBookPayloadReader.kt +++ b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaBookPayloadReader.kt @@ -134,6 +134,8 @@ internal class SefariaBookPayloadReader( val collectiveTitleEn = (schemaJson["collective_title"] as? JsonObject) ?.get("en")?.stringOrNull()?.trim()?.takeIf { it.isNotEmpty() } val titleAliasKeys = extractTitleAliasKeys(schemaJson, schemaObj) + val isReferenceWork = schemaJson["categories"]?.jsonArray + ?.firstOrNull()?.jsonPrimitive?.contentOrNull == "Reference" BookPayload( heTitle = hebrewTitle, @@ -150,6 +152,7 @@ internal class SefariaBookPayloadReader( baseTextTitleKeys = baseTextTitleKeys, collectiveTitleEn = collectiveTitleEn, titleAliasKeys = titleAliasKeys, + isReferenceWork = isReferenceWork, ) }.onFailure { e -> logger.w(e) { "Failed to prepare book from $textPath" } diff --git a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaDirectImporter.kt b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaDirectImporter.kt index e8057d99..c3336e25 100644 --- a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaDirectImporter.kt +++ b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaDirectImporter.kt @@ -280,6 +280,7 @@ class SefariaDirectImporter( // Resolved in a second pass; left empty for now. baseTextBookIds = emptySet(), collectiveTitleEn = payload.collectiveTitleEn, + isReferenceWork = payload.isReferenceWork, ) if (payload.baseTextTitleKeys.isNotEmpty()) { pendingBaseTextKeysByBookId[bookId] = payload.baseTextTitleKeys diff --git a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaImportModels.kt b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaImportModels.kt index 035f899b..26b03828 100644 --- a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaImportModels.kt +++ b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaImportModels.kt @@ -41,6 +41,10 @@ internal data class BookMeta( // "Rashi on Exodus"…). Used by the density chain to aggregate per-collective // signal so volume-level noise doesn't tip the per-pair ratio. val collectiveTitleEn: String? = null, + // Sefaria `Reference` work (dictionary, lexicon…). It explains words of other + // texts without being their base or their commentary, so a link between a + // reference work and a non-reference book is never oriented. + val isReferenceWork: Boolean = false, ) internal data class BookPayload( @@ -65,6 +69,8 @@ internal data class BookPayload( // so that title-pattern base parsing ("X on Y") can resolve "Y" to a bookId // when "Y" is a Sefaria-recognised alias (e.g. "Avot" → Pirkei Avot). val titleAliasKeys: List = emptyList(), + // Top-level Sefaria category is `Reference` (dictionaries, lexicons, bibliographies). + val isReferenceWork: Boolean = false, ) internal data class RefEntry( diff --git a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaLinksImporter.kt b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaLinksImporter.kt index e4450ccd..6af82aeb 100644 --- a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaLinksImporter.kt +++ b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaLinksImporter.kt @@ -591,6 +591,15 @@ internal fun resolveDirectionalConnectionTypesForMeta( return baseType to baseType } + // A reference work (e.g. Otzar La'azei Rashi, a dictionary of Rashi's Old French + // glosses) is neither the base nor a commentary of the texts it explains. Sefaria + // still types those rows `commentary`, and since a dictionary has no `dependence` + // the asymmetry below would make it Rashi's base (Zayit issue #503). Links inside + // the reference category (Hafla'ah she-ba'Arakhin on Sefer HeArukh) stay oriented. + if (sourceMeta.isReferenceWork != targetMeta.isReferenceWork) { + return ConnectionType.REFERENCE to ConnectionType.REFERENCE + } + // (1) Strongest signal: explicit base_text_titles declaration. val targetDependsOnSource = sourceBookId in targetMeta.baseTextBookIds val sourceDependsOnTarget = targetBookId in sourceMeta.baseTextBookIds diff --git a/generator/sefariasqlite/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaLinksImporterTest.kt b/generator/sefariasqlite/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaLinksImporterTest.kt index bbb8e8e8..9da25e39 100644 --- a/generator/sefariasqlite/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaLinksImporterTest.kt +++ b/generator/sefariasqlite/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaLinksImporterTest.kt @@ -64,6 +64,55 @@ class SefariaLinksImporterTest { assertEquals(ConnectionType.COMMENTARY, reverse) } + // Zayit #503: Sefaria types Otzar La'azei Rashi → Rashi on X rows `commentary`. + // The dictionary has no dependence, so it must not become Rashi's base. + @Test + fun referenceWorkIsNeverOrientedAgainstNonReferenceBook() { + val dictionary = BookMeta(isBaseBook = false, categoryLevel = 1, priorityRank = null, isReferenceWork = true) + val rashi = BookMeta( + isBaseBook = false, + categoryLevel = 2, + priorityRank = null, + dependence = Dependence.COMMENTARY, + ) + + val (forward, reverse) = resolveDirectionalConnectionTypesForMeta( + baseType = ConnectionType.COMMENTARY, + sourceBookId = 10L, + targetBookId = 20L, + sourceMeta = dictionary, + targetMeta = rashi + ) + + assertEquals(ConnectionType.REFERENCE, forward) + assertEquals(ConnectionType.REFERENCE, reverse) + } + + // A commentary on a dictionary (Hafla'ah she-ba'Arakhin on Sefer HeArukh) stays oriented. + @Test + fun commentaryInsideReferenceCategoryKeepsOrientation() { + val arukh = BookMeta(isBaseBook = false, categoryLevel = 1, priorityRank = null, isReferenceWork = true) + val haflaah = BookMeta( + isBaseBook = false, + categoryLevel = 1, + priorityRank = null, + dependence = Dependence.COMMENTARY, + baseTextBookIds = setOf(10L), + isReferenceWork = true, + ) + + val (forward, reverse) = resolveDirectionalConnectionTypesForMeta( + baseType = ConnectionType.COMMENTARY, + sourceBookId = 10L, + targetBookId = 20L, + sourceMeta = arukh, + targetMeta = haflaah + ) + + assertEquals(ConnectionType.COMMENTARY, forward) + assertEquals(ConnectionType.SOURCE, reverse) + } + // When base_text_titles couldn't be resolved (e.g. the base book is not in // our DB), fall back to schema dependence flag. @Test