Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/backend/server/routes/vontology_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.").
Expand All @@ -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

Expand Down
6 changes: 5 additions & 1 deletion src/backend/services/chat_concept_reference_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}

Expand Down
39 changes: 33 additions & 6 deletions src/backend/vontology/utils_vontology.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down Expand Up @@ -1504,24 +1512,43 @@ 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:
logger.debug(
"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)

# 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"
Expand Down Expand Up @@ -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)
Expand Down
31 changes: 15 additions & 16 deletions src/frontend/web/von_interface/static/js/dynamicTabs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 || '';
Expand All @@ -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();
Expand Down
31 changes: 28 additions & 3 deletions tests/backend/test_chat_concept_reference_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
95 changes: 94 additions & 1 deletion tests/backend/test_vontology_node_content_md_reconstruction.py
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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"<h1>{canonical_name}</h1>"
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"
12 changes: 9 additions & 3 deletions tests/backend/test_vontology_node_content_route_soft.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -128,14 +131,17 @@ 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(
app_client, monkeypatch
):
_, 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",
Expand Down
Loading
Loading