diff --git a/docs/engineering/unified_conversations_and_participant_profiles.md b/docs/engineering/unified_conversations_and_participant_profiles.md index 63294c95..63ad88c9 100644 --- a/docs/engineering/unified_conversations_and_participant_profiles.md +++ b/docs/engineering/unified_conversations_and_participant_profiles.md @@ -181,6 +181,21 @@ retain the source locator; messages remain canonical message concepts. Reference are stable per actor/exchange, not shared ownership or an access grant. Repeated copies recheck source access. No existing message migration is required. +## Conversation names through concept references + +Chat history remains canonical for ordinary conversation titles. Conversation +concept name retrieval projects the current title for the authenticated owner, +using bounded batches of exact owner/session metadata reads. Renames therefore +appear without copying names into Vontology or changing concept visibility. +Unnamed sources use “Conversation”. Shared participants continue to dereference +through the existing source access and accepted-invitation checks. + +The browser's compact-reference metadata applies the same per-viewer name +preference as the conversation list. It returns `canonical_session_name` +separately and identifies `session_name_source`, so a private display preference +is not presented as a change to the source title. Full transcript retrieval +continues to return the canonical source title. + ## Title, participant and topic search The Conversations search field now combines loaded title/participant matches diff --git a/src/backend/server/routes/von_routes.py b/src/backend/server/routes/von_routes.py index 16ec7c91..52674bab 100644 --- a/src/backend/server/routes/von_routes.py +++ b/src/backend/server/routes/von_routes.py @@ -19932,6 +19932,16 @@ def conversation_reference_route(): if not result.get("success"): return jsonify({"error": "Conversation or turn unavailable"}), 404 if data.get("metadata_only") is True: + from ...services.conversation_management_service import ( + apply_conversation_preferences, + ) + + canonical_name = result.get("session_name") + result = apply_conversation_preferences( + actor_user_id=actor, conversations=[result] + )[0] + result["canonical_session_name"] = canonical_name + result.setdefault("session_name_source", "chat_history") result = { key: result.get(key) for key in ( @@ -19939,6 +19949,8 @@ def conversation_reference_route(): "concept_reference", "session_id", "session_name", + "canonical_session_name", + "session_name_source", "turn_id", "open_action", ) diff --git a/src/backend/services/concept_service.py b/src/backend/services/concept_service.py index 11171dfe..180dddb4 100644 --- a/src/backend/services/concept_service.py +++ b/src/backend/services/concept_service.py @@ -1982,9 +1982,15 @@ def _language_rank(candidate_language: Any) -> int: return 1 return 2 if candidate else 3 + from .conversation_concept_service import resolve_conversation_names + + conversation_names = resolve_conversation_names(docs_by_id.values()) type_rank = {"NL": 0, "ABBR": 1} resolved: Dict[str, str] = {} for concept_id, concept_doc in docs_by_id.items(): + if concept_id in conversation_names: + resolved[concept_id] = conversation_names[concept_id] + continue candidates: list[tuple[tuple[int, int, str, str], str]] = [] for row in rows_by_concept.get(concept_id, []): if not isinstance(row, dict): diff --git a/src/backend/services/conversation_concept_service.py b/src/backend/services/conversation_concept_service.py index 4610c42e..c712d6a1 100644 --- a/src/backend/services/conversation_concept_service.py +++ b/src/backend/services/conversation_concept_service.py @@ -49,6 +49,57 @@ class ConversationConceptError(Exception): pass +def resolve_conversation_names(concepts: Iterable[Dict[str, Any]]) -> Dict[str, str]: + """Project current source titles for the authenticated owner's concepts. + + Titles remain in chat history. Exact owner/session pairs are read in batches; + a concept ID or a stored owner identifier alone never authorises the read. + Shared participants use the existing source-reference/invitation route. + """ + candidates = [doc for doc in concepts if _is_visible_conversation_document(doc)] + if not candidates: + return {} + from ..security.access_control import get_effective_user_concept_id + from .chat_history_service import ( + get_chat_history_session_summaries_for_owner_sessions, + ) + + actor = get_effective_user_concept_id() + if not actor: + return {} + pairs_by_id = {} + for doc in candidates: + owners = doc.get("relationships", {}).get(PREDICATE_HAS_OWNER, []) + owners = [owners] if isinstance(owners, str) else owners + session_id = (doc.get("metadata") or {}).get("session_id") + if owners == [actor] and isinstance(session_id, str) and session_id.strip(): + pairs_by_id[doc["concept_id"]] = (actor, session_id.strip()) + pairs = list(dict.fromkeys(pairs_by_id.values())) + summaries = {} + try: + for offset in range(0, len(pairs), 50): + summaries.update( + get_chat_history_session_summaries_for_owner_sessions( + pairs[offset : offset + 50], limit=50 + ) + ) + except Exception: + logger.warning( + "Conversation source names temporarily unavailable", exc_info=True + ) + result = {} + for concept_id, pair in pairs_by_id.items(): + summary = summaries.get(pair) + if isinstance(summary, dict): + title = summary.get("session_name") + result[concept_id] = ( + title.strip() + if isinstance(title, str) and title.strip() + else "Conversation" + ) + return result + + def _generate_conversation_concept_id(session_id: str) -> str: """Generate a concept_id for a conversation based on session_id. @@ -686,6 +737,8 @@ def _build_conversation_response(doc: Dict[str, Any]) -> Dict[str, Any]: participants = relationships.get(PREDICATE_HAS_PARTICIPANT, []) organisation = (relationships.get(PREDICATE_HAS_ORGANISATION) or [None])[0] + name = resolve_conversation_names([doc]).get(conversation_concept_id, name) + return { "conversation_concept_id": conversation_concept_id, "session_id": session_id, diff --git a/src/backend/vontology/utils_vontology.py b/src/backend/vontology/utils_vontology.py index 3986589b..68ba7134 100644 --- a/src/backend/vontology/utils_vontology.py +++ b/src/backend/vontology/utils_vontology.py @@ -532,6 +532,12 @@ def get_concept_display_name_with_names_fallback(concept: dict) -> str: if not isinstance(concept, dict): return "Unnamed Concept" + from ..services.conversation_concept_service import resolve_conversation_names + + source_name = resolve_conversation_names([concept]).get(concept.get("concept_id")) + if source_name is not None: + return source_name + # Try names array first - look for type "NL" names = concept.get("names") if isinstance(names, list): diff --git a/tests/backend/test_compact_conversation_reference.py b/tests/backend/test_compact_conversation_reference.py index 7366e3ec..abc47473 100644 --- a/tests/backend/test_compact_conversation_reference.py +++ b/tests/backend/test_compact_conversation_reference.py @@ -227,3 +227,50 @@ def test_source_change_between_anchor_and_page_is_retryable(source, monkeypatch) result = read(build_turn_reference(PARENT, "a-stored")) assert result["success"] is False assert result["error_code"] == "conversation_turn_changed" + + +def test_named_reference_metadata_matches_viewer_state_and_preserves_source( + monkeypatch, source +): + from src.backend.services import conversation_management_service as management + + monkeypatch.setattr( + von_routes, + "get_effective_context", + lambda *a: {"namespace": "#V#alice@org", "organisation_id": "#V#org"}, + ) + preference = {"session_name_override": "My conversation name"} + monkeypatch.setattr( + management, + "get_conversation_preferences", + lambda **kw: {"source-session": preference}, + ) + app = Flask(__name__) + app.secret_key = "fixture-only" + app.register_blueprint(von_routes.von_bp, url_prefix="/von") + with app.test_client() as client: + with client.session_transaction() as session: + session["user_concept_id"] = "#V#alice" + + def metadata(): + response = client.post( + "/von/api/session/conversation_reference", + json={"conversation_ref": PARENT, "metadata_only": True}, + ) + assert response.status_code == 200 + return response.json + + result = metadata() + assert result["session_name"] == "My conversation name" + assert result["canonical_session_name"] == "Original title" + assert result["session_name_source"] == "actor_preference" + assert "messages" not in result + preference.clear() + source["title"] = "Renamed canonical title" + result = metadata() + assert ( + result["session_name"] + == result["canonical_session_name"] + == "Renamed canonical title" + ) + assert result["session_name_source"] == "chat_history" diff --git a/tests/backend/test_conversation_concept_service.py b/tests/backend/test_conversation_concept_service.py index 3c4e6290..2b9b87d2 100644 --- a/tests/backend/test_conversation_concept_service.py +++ b/tests/backend/test_conversation_concept_service.py @@ -169,9 +169,7 @@ def _text_value_find(query, **_kwargs): # noqa: ANN001 monkeypatch.setattr( service.ConceptsRepository, "find", - lambda *_args, **_kwargs: [ - _conversation_doc("#V#legacy_visible_conversation") - ], + lambda *_args, **_kwargs: [_conversation_doc("#V#legacy_visible_conversation")], ) assert ( @@ -335,3 +333,80 @@ def test_focal_conversation_backlinks_are_bounded_and_source_scoped() -> None: assert backlinks["source_concept_id"] == "#V#task" assert len(backlinks["items"]) == 25 assert backlinks["more_count"] == 5 + + +def test_source_name_is_current_across_concept_retrieval_paths(monkeypatch): + from src.backend.services import chat_history_service, concept_service + from src.backend.security.access_control import override_current_actor + from src.backend.vontology.utils_vontology import ( + get_concept_display_name_with_names_fallback, + ) + + doc = _conversation_doc("#V#conversation_named") + doc["metadata"] = {"session_id": "named-session"} + doc["relationships"][service.PREDICATE_HAS_OWNER] = ["#V#owner"] + doc["names"] = [{"name": "Obsolete title", "type": "NL", "language": "en-NZ"}] + state = {"title": "Canonical title"} + calls = [] + + def summaries(pairs, **kwargs): + calls.append(pairs) + return {("#V#owner", "named-session"): {"session_name": state["title"]}} + + monkeypatch.setattr( + chat_history_service, + "get_chat_history_session_summaries_for_owner_sessions", + summaries, + ) + monkeypatch.setattr(service.ConceptsRepository, "find_one", lambda *a, **kw: doc) + monkeypatch.setattr( + "src.backend.services.text_value_service.get_texts_for_concept", + lambda *a, **kw: [], + ) + monkeypatch.setattr(concept_service, "get_texts_for_concepts", lambda *a, **kw: {}) + with override_current_actor("#V#owner", None): + for title, expected in [ + ("Canonical title", "Canonical title"), + ("Renamed – Māori", "Renamed – Māori"), + (" ", "Conversation"), + ]: + state["title"] = title + assert ( + service.get_conversation_concept(doc["concept_id"])["name"] == expected + ) + assert get_concept_display_name_with_names_fallback(doc) == expected + assert concept_service.resolve_concept_display_names( + [doc], preferred_language="en-NZ" + ) == {doc["concept_id"]: expected} + assert all(pairs == [("#V#owner", "named-session")] for pairs in calls) + assert doc["names"][0]["name"] == "Obsolete title" # No source mutation. + + +def test_source_names_require_authenticated_owner_and_batch_reads(monkeypatch): + from src.backend.services import chat_history_service + from src.backend.security.access_control import override_current_actor + + docs = [] + for i in range(53): + doc = _conversation_doc(f"#V#conversation_{i}") + doc["metadata"] = {"session_id": str(i)} + doc["relationships"][service.PREDICATE_HAS_OWNER] = ["#V#owner"] + docs.append(doc) + calls = [] + + def summaries(pairs, **kwargs): + calls.append(pairs) + return {pair: {"session_name": "Title " + pair[1]} for pair in pairs} + + monkeypatch.setattr( + chat_history_service, + "get_chat_history_session_summaries_for_owner_sessions", + summaries, + ) + for actor in (None, "#V#other"): + with override_current_actor(actor, None): + assert service.resolve_conversation_names(docs) == {} + assert calls == [] + with override_current_actor("#V#owner", None): + assert len(service.resolve_conversation_names(docs)) == 53 + assert [len(pairs) for pairs in calls] == [50, 3]