From 81a1554b7bc2296a1b7b270136f889f46a8a0038 Mon Sep 17 00:00:00 2001 From: witbrock Date: Tue, 18 Aug 2026 14:49:33 +0200 Subject: [PATCH] Fix canonical Vontology name projection --- src/backend/server/routes/vontology_routes.py | 8 +- .../chat_concept_reference_service.py | 6 +- src/backend/vontology/utils_vontology.py | 39 +++++- .../von_interface/static/js/dynamicTabs.js | 31 +++-- .../test_chat_concept_reference_service.py | 31 ++++- ...von_generate_context_concept_references.py | 7 +- ...ontology_node_content_md_reconstruction.py | 95 +++++++++++++- .../test_vontology_node_content_route_soft.py | 12 +- tests/frontend/dynamicTabsIdCollision.test.js | 123 +++++++++++++++--- 9 files changed, 303 insertions(+), 49 deletions(-) diff --git a/src/backend/server/routes/vontology_routes.py b/src/backend/server/routes/vontology_routes.py index 370f0ecb5..07347425a 100644 --- a/src/backend/server/routes/vontology_routes.py +++ b/src/backend/server/routes/vontology_routes.py @@ -1183,7 +1183,9 @@ def get_node_content_route(): if not identifier: return jsonify({"error": "Missing 'identifier' parameter."}), 400 try: - data = get_vontology_node_content(identifier) + data = get_vontology_node_content( + identifier, resolve_display_name=not raw_only + ) # Heuristic: strip trailing punctuation accidentally attached to #V# identifiers. # This reduces noisy 404s when IDs appear at sentence boundaries (e.g. "#V#foo."). @@ -1194,7 +1196,9 @@ def get_node_content_route(): ): stripped = identifier.rstrip(".,:;!?)]}…") if stripped != identifier and stripped.startswith("#V#"): - retry = get_vontology_node_content(stripped) + retry = get_vontology_node_content( + stripped, resolve_display_name=not raw_only + ) if "error" not in retry: data = retry diff --git a/src/backend/services/chat_concept_reference_service.py b/src/backend/services/chat_concept_reference_service.py index 66c82cfcd..2baab465d 100644 --- a/src/backend/services/chat_concept_reference_service.py +++ b/src/backend/services/chat_concept_reference_service.py @@ -286,7 +286,11 @@ def _lookup_concept_entry( """Return (entry, direct_parent_ids_for_types).""" try: - node = get_vontology_node_content(concept_id, reconstruct_md=False) + node = get_vontology_node_content( + concept_id, + reconstruct_md=False, + resolve_display_name=False, + ) except Exception: node = {"error": "lookup_failed"} diff --git a/src/backend/vontology/utils_vontology.py b/src/backend/vontology/utils_vontology.py index 54921b55f..3986589ba 100644 --- a/src/backend/vontology/utils_vontology.py +++ b/src/backend/vontology/utils_vontology.py @@ -1358,10 +1358,18 @@ def get_all_vontology_nodes_with_details(identifier: str = "Thing"): } -def get_vontology_node_content(identifier: str, *, reconstruct_md: bool = True) -> dict: +def get_vontology_node_content( + identifier: str, + *, + reconstruct_md: bool = True, + resolve_display_name: bool = True, +) -> dict: """ Fetches a Vontology concept's details from MongoDB by its path, concept_id, or _id. Renders markdown content to HTML. + + ``resolve_display_name=False`` is an internal hot-path option for callers + that immediately perform their own canonical hasName projection. """ if not identifier: logger.error("Identifier cannot be empty for get_vontology_node_content.") @@ -1504,6 +1512,28 @@ def convert_objectids_to_strings(obj): convert_objectids_to_strings(raw_doc_copy) + # Resolve the display projection through canonical hasName relations before + # reconstructing Markdown. Raw concept documents commonly omit names now, + # and identifier humanisation cannot recover authored acronyms or Unicode. + # Keep the legacy accessor as a fail-soft compatibility fallback. + resolved_display_name = get_concept_display_name_with_names_fallback(doc) + if resolve_display_name: + try: + from ..services.concept_service import resolve_concept_display_names + + concept_id = doc.get("concept_id") + canonical_name = resolve_concept_display_names( + [doc], preferred_language=_get_cached_preferred_language() + ).get(concept_id) + if isinstance(canonical_name, str) and canonical_name.strip(): + resolved_display_name = canonical_name + except Exception as display_name_err: + logger.debug( + "Canonical display-name projection failed for '%s'; using legacy fallback: %s", + identifier, + display_name_err, + ) + md_content = doc.get("md_content") if md_content is None and reconstruct_md: @@ -1511,9 +1541,6 @@ def convert_objectids_to_strings(obj): "Markdown content (md_content) missing for '%s'. Reconstructing basic version.", identifier, ) - # Try to get name from multiple possible locations, with human-readable fallback - # Prefer names[] "NL" entry only. - name = get_concept_display_name_with_names_fallback(doc) # Use accessor functions for description and notes description = get_concept_description(doc) notes = get_concept_notes(doc) @@ -1521,7 +1548,7 @@ def convert_objectids_to_strings(obj): # NOTE: Older versions reconstructed md_content with boilerplate metadata # (Source Concept/SubConcept Of/Instance Of). Those placeholders are noisy # and redundant with UI fields, so keep the fallback minimal. - md_content = f"# {name}\n\n" + md_content = f"# {resolved_display_name}\n\n" if description: md_content += f"## Description\n\n{description}\n\n" @@ -1579,7 +1606,7 @@ def convert_objectids_to_strings(obj): # Callers must use 'display_name' (preferred) or inspect names[] inside raw_doc. payload = { "content_html": html, - "display_name": get_concept_display_name_with_names_fallback(doc), + "display_name": resolved_display_name, "concept_id": doc.get("concept_id"), "path": doc.get("path"), # Top-level 'description' intentionally suppressed globally (JVNAUTOSCI-573 rollout) diff --git a/src/frontend/web/von_interface/static/js/dynamicTabs.js b/src/frontend/web/von_interface/static/js/dynamicTabs.js index e4baa02c0..167f17a18 100644 --- a/src/frontend/web/von_interface/static/js/dynamicTabs.js +++ b/src/frontend/web/von_interface/static/js/dynamicTabs.js @@ -4332,6 +4332,8 @@ async function populateTypeDescription(conceptId, suffix) { const saveBtn = document.getElementById(`typeEditDescriptionSave_${suffix}`); const cancelBtn = document.getElementById(`typeEditDescriptionCancel_${suffix}`); const statusEl = document.getElementById(`typeDescriptionStatus_${suffix}`); + if (display) display.dir = 'auto'; + if (textarea) textarea.dir = 'auto'; const ensureDescriptionMetadataContainer = () => { let meta = document.getElementById(`typeDescriptionMetadata_${suffix}`); if (meta) return meta; @@ -4558,12 +4560,23 @@ async function populateTypeDescription(conceptId, suffix) { } } - // Final fallback: node_content (may provide md_content; if only HTML, convert carefully). + // Final persisted legacy fallback: node_content. const legacyUrl = `/vontology/api/vontology/node_content?identifier=${encodedId}`; res = await fetch(legacyUrl); if (res.ok) { data = await res.json().catch(() => ({})); - const legacyRaw = data.description || data.md_content || null; + // node_content reconstructs a title-only Markdown document when + // no md_content is stored. That derived document is not an + // authored description. Preserve only genuinely persisted + // legacy Markdown (or an explicit description if one is ever + // restored to this compatibility response). + const explicitLegacyDescription = typeof data.description === 'string' + ? data.description + : null; + const persistedLegacyMarkdown = typeof data?.raw_doc?.md_content === 'string' + ? data.raw_doc.md_content + : null; + const legacyRaw = explicitLegacyDescription ?? persistedLegacyMarkdown; if (legacyRaw !== null) { applyRawDescription(legacyRaw); textarea.value = legacyRaw || ''; @@ -4572,20 +4585,6 @@ async function populateTypeDescription(conceptId, suffix) { console.debug('[dynamicTabs] Description loaded (legacy node_content raw text)', { conceptId }); return; } - - // Last-resort: if the backend only provided rendered HTML, derive a paragraph-preserving - // editable representation using DOM parsing. - if (data.content_html) { - const tmp = document.createElement('div'); - tmp.innerHTML = data.content_html; - const plain = extractDescriptionPlainText(tmp); - applyRawDescription(plain); - textarea.value = plain; - relationId = null; - renderDescriptionMetadata(null); - console.debug('[dynamicTabs] Description loaded (legacy node_content html->plain)', { conceptId }); - return; - } } console.debug('[dynamicTabs] No description found after all fallbacks', { conceptId }); setEmpty(); diff --git a/tests/backend/test_chat_concept_reference_service.py b/tests/backend/test_chat_concept_reference_service.py index b86487a1a..8bd15f01e 100644 --- a/tests/backend/test_chat_concept_reference_service.py +++ b/tests/backend/test_chat_concept_reference_service.py @@ -8,7 +8,17 @@ def test_build_context_concept_reference_metadata_classifies_and_marks_missing( monkeypatch, ): - def _stub_node_content(concept_id: str, *, reconstruct_md: bool = True): + node_content_calls = [] + + def _stub_node_content( + concept_id: str, + *, + reconstruct_md: bool = True, + resolve_display_name: bool = True, + ): + node_content_calls.append( + (concept_id, reconstruct_md, resolve_display_name) + ) if concept_id == "#V#person": return { "concept_id": concept_id, @@ -80,10 +90,20 @@ def _stub_find(*_args, **_kwargs): assert by_id["#V#missing"]["exists"] is False assert by_id["#V#missing"]["kind"] is None assert by_id["#V#missing"]["name"] is None + assert node_content_calls == [ + ("#V#person", False, False), + ("#V#has_email", False, False), + ("#V#missing", False, False), + ] def test_build_context_concept_reference_metadata_enforces_concept_cap(monkeypatch): - def _stub_node_content(concept_id: str, *, reconstruct_md: bool = True): + def _stub_node_content( + concept_id: str, + *, + reconstruct_md: bool = True, + resolve_display_name: bool = True, + ): return { "concept_id": concept_id, "display_name": concept_id, @@ -121,7 +141,12 @@ def _stub_node_content(concept_id: str, *, reconstruct_md: bool = True): def test_build_context_concept_reference_metadata_attaches_stats_when_available( monkeypatch, ): - def _stub_node_content(concept_id: str, *, reconstruct_md: bool = True): + def _stub_node_content( + concept_id: str, + *, + reconstruct_md: bool = True, + resolve_display_name: bool = True, + ): return { "concept_id": concept_id, "display_name": concept_id, diff --git a/tests/backend/test_von_generate_context_concept_references.py b/tests/backend/test_von_generate_context_concept_references.py index 0d15aaf19..3329d6c5b 100644 --- a/tests/backend/test_von_generate_context_concept_references.py +++ b/tests/backend/test_von_generate_context_concept_references.py @@ -40,7 +40,12 @@ def app(monkeypatch): lambda _user_id, **_kwargs: [], ) - def _stub_node_content(concept_id: str, *, reconstruct_md: bool = True): + def _stub_node_content( + concept_id: str, + *, + reconstruct_md: bool = True, + resolve_display_name: bool = True, + ): if concept_id == "#V#person": return { "concept_id": concept_id, diff --git a/tests/backend/test_vontology_node_content_md_reconstruction.py b/tests/backend/test_vontology_node_content_md_reconstruction.py index 5101f6453..f482f9559 100644 --- a/tests/backend/test_vontology_node_content_md_reconstruction.py +++ b/tests/backend/test_vontology_node_content_md_reconstruction.py @@ -1,7 +1,7 @@ import pytest -from src.backend.vontology import utils_vontology from src.backend.db.repositories.concepts_repository import ConceptsRepository +from src.backend.vontology import utils_vontology @pytest.fixture(autouse=True) @@ -139,3 +139,96 @@ def _fake_get_concept_by_concept_id(requested_id: str): assert payload["concept_id"] == concept_id assert payload["display_name"] == "Service Visible Concept" assert payload["kind"] == "individual" + + +@pytest.mark.parametrize( + "canonical_name", + [ + "UoA CS PhD Student", + "Študentka doktorskega študija", + "E\u0301tudiante en IA", + "طالبة دكتوراه", + ], +) +def test_reconstructed_heading_uses_exact_canonical_relation_backed_name( + monkeypatch, + canonical_name, +): + concept_id = "#V#uo_acs_ph_d_student" + concept_doc = { + "_id": "696c975991de325e9d1cf3ce", + "concept_id": concept_id, + "relationships": { + "is_an_instance_of": [], + "is_a_type_of": ["#V#student"], + }, + } + projection_calls = [] + + monkeypatch.setattr( + utils_vontology.ConceptsRepository, + "find_one", + lambda *_args, **_kwargs: dict(concept_doc), + ) + monkeypatch.setattr(utils_vontology, "get_concept_description", lambda _doc: None) + monkeypatch.setattr(utils_vontology, "get_concept_notes", lambda _doc: None) + + monkeypatch.setattr( + utils_vontology, "_get_cached_preferred_language", lambda: "en-NZ" + ) + + def _resolve_display_names(docs, *, preferred_language=None): + projection_calls.append((docs, preferred_language)) + assert docs[0]["concept_id"] == concept_id + return {concept_id: canonical_name} + + monkeypatch.setattr( + "src.backend.services.concept_service.resolve_concept_display_names", + _resolve_display_names, + ) + + payload = utils_vontology.get_vontology_node_content(concept_id) + + assert len(projection_calls) == 1 + assert projection_calls[0][1] == "en-NZ" + assert payload["display_name"] == canonical_name + assert payload["md_content"] == f"# {canonical_name}\n\n" + assert payload["content_html"] == f"

{canonical_name}

" + assert [ord(char) for char in payload["display_name"]] == [ + ord(char) for char in canonical_name + ] + + +def test_node_content_display_name_projection_fails_soft_to_legacy_name(monkeypatch): + concept_id = "#V#legacy_named_concept" + concept_doc = { + "_id": "696c975991de325e9d1cf3cd", + "concept_id": concept_id, + "names": [ + { + "name": "Legacy Živjo", + "type": "NL", + "language": "en-NZ", + } + ], + "relationships": {"is_an_instance_of": [], "is_a_type_of": []}, + } + + monkeypatch.setattr( + utils_vontology.ConceptsRepository, + "find_one", + lambda *_args, **_kwargs: dict(concept_doc), + ) + monkeypatch.setattr(utils_vontology, "get_concept_description", lambda _doc: None) + monkeypatch.setattr(utils_vontology, "get_concept_notes", lambda _doc: None) + monkeypatch.setattr( + "src.backend.services.concept_service.resolve_concept_display_names", + lambda _docs, **_kwargs: (_ for _ in ()).throw( + RuntimeError("relation read unavailable") + ), + ) + + payload = utils_vontology.get_vontology_node_content(concept_id) + + assert payload["display_name"] == "Legacy Živjo" + assert payload["md_content"] == "# Legacy Živjo\n\n" diff --git a/tests/backend/test_vontology_node_content_route_soft.py b/tests/backend/test_vontology_node_content_route_soft.py index 58e2707cd..90d26dda0 100644 --- a/tests/backend/test_vontology_node_content_route_soft.py +++ b/tests/backend/test_vontology_node_content_route_soft.py @@ -93,7 +93,7 @@ def __init__(self, *args, **kwargs): def test_node_content_default_returns_404_for_missing_concept(app_client, monkeypatch): _, client = app_client - def _fake_get(_identifier: str): + def _fake_get(_identifier: str, **_kwargs): return {"error": "Concept '#V#missing' not found in MongoDB."} monkeypatch.setattr( @@ -112,7 +112,10 @@ def _fake_get(_identifier: str): def test_node_content_soft_returns_200_with_not_found_marker(app_client, monkeypatch): _, client = app_client - def _fake_get(_identifier: str): + calls = [] + + def _fake_get(_identifier: str, **kwargs): + calls.append((_identifier, kwargs)) return {"error": "Concept '#V#missing' not found in MongoDB."} monkeypatch.setattr( @@ -128,6 +131,9 @@ def _fake_get(_identifier: str): payload = resp.get_json() assert payload["error"] == "Concept '#V#missing' not found in MongoDB." assert payload["not_found"] is True + assert calls == [ + ("#V#missing", {"resolve_display_name": False}), + ] def test_node_content_returns_403_for_access_denied_exact_concept( @@ -135,7 +141,7 @@ def test_node_content_returns_403_for_access_denied_exact_concept( ): _, client = app_client - def _fake_get(_identifier: str): + def _fake_get(_identifier: str, **_kwargs): return { "error": "Concept '#V#private' is not accessible in the current context.", "error_code": "access_denied", diff --git a/tests/frontend/dynamicTabsIdCollision.test.js b/tests/frontend/dynamicTabsIdCollision.test.js index 6a5a4bc94..36b3d384c 100644 --- a/tests/frontend/dynamicTabsIdCollision.test.js +++ b/tests/frontend/dynamicTabsIdCollision.test.js @@ -536,6 +536,60 @@ describe('notes and content editor markup hygiene', () => { }); describe('newly-created concept description hydration', () => { + const mountDescriptionElements = (suffix) => { + document.body.insertAdjacentHTML('beforeend', ` +
+
+
+
+ + + + + +
+ +
+
+
+ `); + }; + + const mockDescriptionFallbackFetch = (nodeContent) => { + global.fetch.mockImplementation(async (url) => { + const requestUrl = String(url); + if (requestUrl.includes('/texts?predicate=hasDescription')) { + return { + ok: true, + status: 200, + json: async () => ({ texts: [], count: 0 }) + }; + } + if (requestUrl.includes('/vontology/api/vontology/text_relations')) { + return { + ok: true, + status: 200, + json: async () => ({ text_relations: [], count: 0 }) + }; + } + if (requestUrl.startsWith('/api/concepts/')) { + return { + ok: true, + status: 200, + json: async () => ({ description: null }) + }; + } + if (requestUrl.includes('/vontology/api/vontology/node_content')) { + return { + ok: true, + status: 200, + json: async () => nodeContent + }; + } + throw new Error(`Unexpected fetch: ${requestUrl}`); + }); + }; + beforeEach(() => { document.body.innerHTML = `
@@ -567,22 +621,7 @@ describe('newly-created concept description hydration', () => { }); const suffix = tabId.replace(/^conceptTab_/, ''); - document.body.insertAdjacentHTML('beforeend', ` -
-
-
-
- - - - - -
- -
-
-
- `); + mountDescriptionElements(suffix); await new Promise((resolve) => setTimeout(resolve, 0)); global.fetch.mockClear(); @@ -605,6 +644,58 @@ describe('newly-created concept description hydration', () => { expect(urls[0]).toContain('/texts?predicate=hasDescription'); expect(document.getElementById(`typeDescriptionDisplay_${suffix}`).textContent).toContain('No description available.'); }); + + test('does not present reconstructed node-content title as a description', async () => { + const { populateTypeDescription } = require(dynamicTabsModulePath); + const conceptId = '#V#uo_acs_ph_d_student'; + const suffix = 'synthetic_description'; + mountDescriptionElements(suffix); + + mockDescriptionFallbackFetch({ + md_content: '# Uo Acs Ph D Student\n\n', + content_html: '

Uo Acs Ph D Student

', + raw_doc: {} + }); + + await populateTypeDescription(conceptId, suffix); + + const display = document.getElementById(`typeDescriptionDisplay_${suffix}`); + const textarea = document.getElementById(`typeDescriptionTextarea_${suffix}`); + expect(display.textContent).toContain('No description available.'); + expect(display.textContent).not.toContain('Uo Acs Ph D Student'); + expect(display.dir).toBe('auto'); + expect(textarea.dir).toBe('auto'); + }); + + test('preserves genuinely persisted legacy node-content Markdown', async () => { + const { populateTypeDescription } = require(dynamicTabsModulePath); + const conceptId = '#V#legacy_markdown_concept'; + const suffix = 'persisted_description'; + const persistedMarkdown = '# Živjo — E\u0301tudiante — طالبة دكتوراه\n\nLegacy body.'; + mountDescriptionElements(suffix); + + mockDescriptionFallbackFetch({ + md_content: persistedMarkdown, + content_html: '

Živjo — E\u0301tudiante — طالبة دكتوراه

Legacy body.

', + raw_doc: { md_content: persistedMarkdown } + }); + + await populateTypeDescription(conceptId, suffix); + + const display = document.getElementById(`typeDescriptionDisplay_${suffix}`); + const textarea = document.getElementById(`typeDescriptionTextarea_${suffix}`); + expect(display.dataset.rawText).toBe(persistedMarkdown); + expect(textarea.value).toBe(persistedMarkdown); + expect(display.textContent).toBe(persistedMarkdown); + expect(Array.from(display.dataset.rawText, (char) => char.codePointAt(0))).toEqual( + Array.from(persistedMarkdown, (char) => char.codePointAt(0)) + ); + expect(Array.from(textarea.value, (char) => char.codePointAt(0))).toEqual( + Array.from(persistedMarkdown, (char) => char.codePointAt(0)) + ); + expect(display.dir).toBe('auto'); + expect(textarea.dir).toBe('auto'); + }); }); describe('persisted concept tab restore', () => {