diff --git a/src/backend/server/routes/vontology_routes.py b/src/backend/server/routes/vontology_routes.py index 370f0ecb..07347425 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 66c82cfc..2baab465 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 54921b55..3986589b 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 e4baa02c..167f17a1 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 b86487a1..8bd15f01 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 0d15aaf1..3329d6c5 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 5101f645..f482f955 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"
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', () => {