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
15 changes: 15 additions & 0 deletions docs/engineering/unified_conversations_and_participant_profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/backend/server/routes/von_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -19932,13 +19932,25 @@ 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 (
"success",
"concept_reference",
"session_id",
"session_name",
"canonical_session_name",
"session_name_source",
"turn_id",
"open_action",
)
Expand Down
6 changes: 6 additions & 0 deletions src/backend/services/concept_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
53 changes: 53 additions & 0 deletions src/backend/services/conversation_concept_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/backend/vontology/utils_vontology.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
47 changes: 47 additions & 0 deletions tests/backend/test_compact_conversation_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
81 changes: 78 additions & 3 deletions tests/backend/test_conversation_concept_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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]
Loading