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
21 changes: 15 additions & 6 deletions src/basic_memory/api/v2/routers/knowledge_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import logfire
from basic_memory import db
from basic_memory.services.exceptions import AmbiguousIdentifierError
from basic_memory.services.directory_deletes import DirectoryDeleteServiceError
from basic_memory.services.note_content_writes import NoteContentMutationServiceError
from basic_memory.ignore_utils import (
Expand Down Expand Up @@ -276,12 +277,20 @@ async def resolve_identifier(
resolution_method = "external_id" if entity else "search"

if not entity:
entity = await link_resolver.resolve_link(
data.identifier,
source_path=data.source_path,
strict=data.strict,
session=session,
)
try:
entity = await link_resolver.resolve_link(
data.identifier,
source_path=data.source_path,
strict=data.strict,
session=session,
)
except AmbiguousIdentifierError as exc:
# A strict resolve refused to guess between several same-title notes (#1148).
# Surface it as 409 so edit/move report the ambiguity and ask for an exact id.
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
Comment on lines +290 to +292

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add an API regression test for ambiguity conflicts

When two notes share a title and /knowledge/resolve receives strict=true, this handler is the only code translating AmbiguousIdentifierError into the promised 409 response. The added tests exercise LinkResolver and relation resolution directly, but no test posts this ambiguous case through the router, leaving the status/detail transport contract and these new lines uncovered despite the repository's 100% coverage requirement.

AGENTS.md reference: AGENTS.md:L260-L260

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in the latest push — test_resolve_identifier_ambiguous_title_returns_409 posts two same-title notes through /knowledge/resolve with strict=true and asserts the 409 plus the disambiguation detail, covering the AmbiguousIdentifierError -> 409 transport in the router.

) from exc
if entity:
if entity.permalink == data.identifier:
resolution_method = "permalink"
Expand Down
21 changes: 14 additions & 7 deletions src/basic_memory/indexing/relation_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from basic_memory import db
from basic_memory.indexing.models import IndexFileJobStatus
from basic_memory.services.exceptions import AmbiguousIdentifierError
from basic_memory.models import Entity
from basic_memory.repository.relation_repository import (
ResolvedRelationWrite,
Expand Down Expand Up @@ -167,13 +168,19 @@ async def resolve_relations(
f"to_name={relation.to_name}"
)
if relation.to_name not in resolved_targets_by_link_text:
resolved_targets_by_link_text[
relation.to_name
] = await self.link_resolver.resolve_link(
relation.to_name,
strict=True,
session=session,
)
try:
resolved_targets_by_link_text[
relation.to_name
] = await self.link_resolver.resolve_link(
relation.to_name,
strict=True,
session=session,
)
except AmbiguousIdentifierError:
# The target title is shared by several notes; we can't safely pick one,
# so leave this relation a forward reference (as if unresolved) instead of
# aborting the whole pass and stranding other resolvable relations (#1148).
resolved_targets_by_link_text[relation.to_name] = None
resolved_entity = resolved_targets_by_link_text[relation.to_name]
if resolved_entity is None or resolved_entity.id == relation.from_id:
continue
Expand Down
22 changes: 22 additions & 0 deletions src/basic_memory/services/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,28 @@ class EntityNotFoundError(Exception):
pass


class AmbiguousIdentifierError(Exception):
"""Raised when a non-exact identifier matches multiple entities under strict resolution.

Strict resolution backs destructive operations (edit, move). It must never silently pick
one of several same-title notes (e.g. an original plus a ``-1`` duplicate), so the caller is
told to disambiguate with an exact permalink or external_id. Non-strict resolution (wiki
links, reads) keeps its shortest-path preference and does not raise. See issue #1148.
"""

def __init__(self, identifier: str, candidates: list[tuple[str | None, str]]) -> None:
self.identifier = identifier
self.candidates = candidates
listing = "; ".join(
f"{file_path} (permalink: {permalink})" if permalink else f"{file_path} (no permalink)"
for permalink, file_path in candidates
)
super().__init__(
f"Ambiguous identifier '{identifier}' matches {len(candidates)} notes: {listing}. "
"Pass an exact permalink or external_id to disambiguate."
)


class EntityCreationError(Exception):
"""Raised when an entity cannot be created"""

Expand Down
66 changes: 56 additions & 10 deletions src/basic_memory/services/link_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from basic_memory.models import Entity, Project
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.repository.project_repository import ProjectRepository
from basic_memory.services.exceptions import AmbiguousIdentifierError
from basic_memory.repository.search_repository import create_search_repository
from basic_memory.schemas.search import SearchQuery, SearchItemType
from basic_memory.services.search_service import SearchService
Expand Down Expand Up @@ -79,6 +80,11 @@ class LinkResolver:
3. Try exact file path match
4. Try file path with .md extension (for folder/title patterns)
5. Fall back to search for fuzzy matching

When ``strict`` is set (the destructive edit/move paths) and a title matches more than one
note, resolution raises ``AmbiguousIdentifierError`` instead of guessing — the caller must pass
an exact permalink or external_id (issue #1148). Non-strict resolution keeps its shortest-path
preference.
"""

def __init__(
Expand Down Expand Up @@ -352,7 +358,29 @@ async def _resolve_in_project(
# Multiple candidates - pick closest to source
return self._find_closest_entity(candidates, source_path)

# Standard resolution (no source context): permalink first, then title
# Standard resolution (no source context): permalink first, then title.
#
# A destructive (strict) resolve must not silently guess between several same-title notes
# — e.g. an original plus a `-1` duplicate — which is how edit/move landed on the wrong
# entity (issue #1148). The trap is that the permalink step also matches the *slug* of the
# title (`build_permalink_resolution_candidates` slugifies the input), so a duplicated title
# whose original owns the title-derived permalink would still resolve silently. Pre-read the
# title matches so the permalink loop can tell a caller-supplied exact permalink (input is
# already in slug form) from the slug of a shared title. Non-strict resolution keeps the
# fast path — the title is read only if the permalink loop misses.
strict_title_matches = (
await entity_repository.get_by_title(session, clean_text, load_relations=load_relations)
if strict
else None
)
strict_ambiguous_title = strict_title_matches is not None and len(strict_title_matches) > 1
# The caller supplied an exact permalink when their verbatim (project-normalized) identifier
# matches a stored permalink. That is the first, un-slugified candidate the builder emits,
# so it also accepts explicit custom permalinks (e.g. "API_V2") that are not slug-shaped —
# inferring exactness from slug shape would wrongly reject them. Only this bypasses the
# duplicate-title guard below (#1148).
exact_identifier = normalize_project_reference(clean_text).strip("/")

# 1. Try exact permalink match first (most efficient)
for candidate_permalink in permalink_candidates:
entity = await entity_repository.get_by_permalink(
Expand All @@ -361,20 +389,30 @@ async def _resolve_in_project(
load_relations=load_relations,
)
if entity:
# The slugified form of a shared title must not silently win for a destructive op;
# only the caller's exact (verbatim) permalink candidate may bypass the guard.
if strict_ambiguous_title and candidate_permalink != exact_identifier:
break
logger.debug(f"Found exact permalink match: {entity.permalink}")
return entity

# 2. Try exact title match
found = await entity_repository.get_by_title(
session,
clean_text,
load_relations=load_relations,
# 2. Try exact title match. An exact file-path match below is more precise than a title and
# can still disambiguate, so defer any ambiguity rejection until the path lookups have run.
found = (
strict_title_matches
if strict
else await entity_repository.get_by_title(
session, clean_text, load_relations=load_relations
)
)
ambiguous_title_candidates: list[Entity] = []
if found:
# Return first match (shortest path) if no source context
entity = found[0]
logger.debug(f"Found title match: {entity.title}")
return entity
if strict and len(found) > 1:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve exact file paths before rejecting title ambiguity

If an exact, project-unique file path is also a duplicated title, this branch raises before the subsequent get_by_file_path() lookup. For example, with root Image.png and nested/Image.png resources whose titles are both Image.png, strict resolution of the exact root path Image.png now returns a 409 even though the file path uniquely identifies the entity and file paths are part of the resolver's documented contract. Check for an exact path match before rejecting duplicate title matches, or exempt a candidate whose file_path exactly equals the identifier.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f604c07. The ambiguous-title rejection is now deferred until after the exact get_by_file_path() lookups, so an exact, project-unique file path resolves to its entity instead of raising. Added a regression test (test_exact_file_path_wins_over_ambiguous_title_in_strict_mode).

ambiguous_title_candidates = list(found)
Comment on lines 409 to +411

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject ambiguity before normalized permalink fallbacks

When one same-title note has a root-level permalink derived from its title, strict resolution still silently selects it: build_permalink_resolution_candidates("Core Service", ...) adds core-service (or the project-prefixed equivalent), so the permalink loop returns that entity before this duplicate-title check runs. Thus an original Core Service.md plus another note with the same title can still make edit/move mutate the original instead of raising; only caller-supplied exact permalink candidates should bypass the ambiguity check.

AGENTS.md reference: AGENTS.md:L132-L133

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in dec1d50. Only a caller-supplied exact permalink (input already in slug form, i.e. input == generate_permalink(input)) now bypasses the duplicate-title guard. A title-shaped input like "Core Service" that merely slugifies to core-service no longer lets the permalink step silently return the original for a strict op — it raises. Exact permalinks still resolve. Title matches are pre-read only in strict mode, so non-strict (wiki-link/read) resolution keeps its permalink fast path. Added a regression test covering both directions.

else:
entity = found[0]
logger.debug(f"Found title match: {entity.title}")
return entity

# 3. Try file path
found_path = await entity_repository.get_by_file_path(
Expand All @@ -398,6 +436,14 @@ async def _resolve_in_project(
logger.debug(f"Found entity with path (with .md): {found_path_md.file_path}")
return found_path_md

# No exact permalink or file path matched. If the only thing that matched was a title
# shared by several notes under a strict resolve, refuse to guess (#1148).
if ambiguous_title_candidates:
raise AmbiguousIdentifierError(
clean_text,
[(entity.permalink, entity.file_path) for entity in ambiguous_title_candidates],
)

# In strict mode, don't try fuzzy search - return None if no exact match found
if strict:
return None
Expand Down
28 changes: 28 additions & 0 deletions tests/api/v2/test_knowledge_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,34 @@ async def test_resolve_identifier_not_found(client: AsyncClient, v2_project_url)
assert "Entity not found" in response.json()["detail"]


@pytest.mark.asyncio
async def test_resolve_identifier_ambiguous_title_returns_409(
client: AsyncClient, v2_project_url
):
"""A strict resolve of a title shared by multiple notes returns 409 (#1148).

Covers the router's AmbiguousIdentifierError -> 409 transport contract: the resolver raising
is not enough, edit/move rely on this handler surfacing the disambiguation message.
"""
for directory in ("reports", "archive"):
create = await client.post(
f"{v2_project_url}/knowledge/entities",
json={"title": "Duplicate Report", "directory": directory, "content": "body"},
)
assert create.status_code == 202

response = await client.post(
f"{v2_project_url}/knowledge/resolve",
json={"identifier": "Duplicate Report", "strict": True},
)

assert response.status_code == 409
detail = response.json()["detail"]
assert "Ambiguous identifier 'Duplicate Report'" in detail
assert "matches 2 notes" in detail
assert "exact permalink or external_id" in detail


@pytest.mark.asyncio
async def test_resolve_identifier_no_fuzzy_match(client: AsyncClient, v2_project_url):
"""Test that resolve uses strict mode - no fuzzy search fallback.
Expand Down
61 changes: 61 additions & 0 deletions tests/indexing/test_relation_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -495,3 +495,64 @@ async def test_repository_runtime_batches_resolution_and_entity_refresh_sessions
)
]
assert FakeSession.created_count == 2


@pytest.mark.asyncio
async def test_resolve_relations_skips_ambiguous_target_without_aborting_pass() -> None:
"""An ambiguous relation target is left unresolved; other relations still resolve (#1148).

strict resolution now raises AmbiguousIdentifierError when a target title matches several
notes. The repair pass must treat that as an unresolved forward reference rather than let the
exception abort the whole batch and strand other resolvable relations.
"""
from basic_memory.services.exceptions import AmbiguousIdentifierError

class AmbiguousStubLinkResolver(StubLinkResolver):
def __init__(
self,
targets: dict[str, FakeResolvedEntity],
ambiguous: set[str],
) -> None:
super().__init__(targets)
self.ambiguous = ambiguous

async def resolve_link(
self,
link_text: str,
*,
strict: bool,
session: AsyncSession,
) -> FakeResolvedEntity | None:
assert isinstance(session, FakeSession)
self.calls.append((link_text, strict))
if link_text in self.ambiguous:
raise AmbiguousIdentifierError(
link_text, [("dup-a", "a.md"), ("dup-b", "b.md")]
)
return self.targets.get(link_text)

repo = StubRelationRepository(
[
[
FakeRelation(id=1, from_id=10, to_name="Ambiguous Title"),
FakeRelation(id=2, from_id=11, to_name="Clear Target"),
]
]
)
link_resolver = AmbiguousStubLinkResolver(
targets={"Clear Target": FakeResolvedEntity(id=20, title="Clear Target")},
ambiguous={"Ambiguous Title"},
)
entity_indexer = StubEntityIndexer()
runtime = build_repository_runtime(repo, link_resolver, entity_indexer)

# The pass completes (no exception propagates) ...
affected = await runtime.resolve_relations()

# ... resolving only the unambiguous relation; the ambiguous one stays a forward reference.
assert affected == {11}
assert len(repo.write_batches) == 1
writes = repo.write_batches[0]
assert [write.relation_id for write in writes] == [2]
assert writes[0].target_id == 20
assert ("Ambiguous Title", True) in link_resolver.calls
Loading
Loading