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
1 change: 0 additions & 1 deletion .github/workflows/claude-issue-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,4 @@ jobs:
- Status: needs-reproduction, needs-clarification, duplicate

Read the issue carefully and provide helpful triage with appropriate labels.

claude_args: '--allowed-tools "Bash(gh issue:*),Bash(gh search:*),Read"'
6 changes: 6 additions & 0 deletions src/basic_memory/index/local_notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ def note_content_repository(self, project_id: ProjectId) -> NoteContentRepositor
def search_repository(self, project_id: ProjectId) -> AcceptedNoteSearchRepository:
return AcceptedNoteSearchRepository(project_id=project_id)

def observation_repository(self, project_id: ProjectId) -> ObservationRepository:
return ObservationRepository(project_id=project_id)

def relation_repository(self, project_id: ProjectId) -> RelationRepository:
return RelationRepository(project_id=project_id)


# --- Current-Note Content Freshening ---

Expand Down
33 changes: 33 additions & 0 deletions src/basic_memory/indexing/accepted_note_mutation_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
AcceptedNoteCreatePreparer,
AcceptedNoteEditPreparer,
AcceptedNoteMovePreparer,
AcceptedNoteSelfRelationResolver,
AcceptedPreparedNoteWrite,
AcceptedNoteReplacePreparer,
AcceptedNoteWriteRepositories,
Expand All @@ -26,6 +27,7 @@
prepare_accepted_note_edit,
prepare_accepted_note_move,
prepare_accepted_note_replace,
replace_accepted_note_graph,
)
from basic_memory.models import Entity, NoteContent, Project
from basic_memory.repository import NoteContentVersionConflict
Expand Down Expand Up @@ -240,6 +242,7 @@ class AcceptedNoteMutationPreparer(
AcceptedNoteReplacePreparer,
AcceptedNoteEditPreparer,
AcceptedNoteMovePreparer,
AcceptedNoteSelfRelationResolver,
Protocol,
):
"""Combined Basic Memory prepare capability for accepted note mutations."""
Expand Down Expand Up @@ -494,6 +497,16 @@ async def _run_accepted_note_create(
updated_at=now,
repositories=dependencies.write_repositories,
)
# Persist observations/relations in the same transaction as the entity and
# note_content. Skipping this left the graph tables empty until a later
# index_file pass reparsed the materialized file (issue #1076).
await replace_accepted_note_graph(
session,
entity=entity,
prepared=prepared,
self_relation_resolver=preparer,
repositories=dependencies.write_repositories,
)
return plan_accepted_note_write_change(
status_code=201,
entity=entity,
Expand Down Expand Up @@ -634,6 +647,16 @@ async def _run_accepted_note_update(
accepted_file_path=entity.file_path,
repositories=dependencies.write_repositories,
)
# Replace the graph atomically: a PUT create-or-replace owns the note's full
# observation/relation set, so stale rows from a prior write are dropped and
# the accepted markdown's rows land in the same transaction (issue #1076).
await replace_accepted_note_graph(
session,
entity=entity,
prepared=prepared,
self_relation_resolver=preparer,
repositories=dependencies.write_repositories,
)
return plan_accepted_note_write_change(
status_code=201 if created else 200,
entity=entity,
Expand Down Expand Up @@ -694,6 +717,16 @@ async def _run_accepted_note_edit(
accepted_file_path=entity.file_path,
repositories=dependencies.write_repositories,
)
# An edit reparses the whole note, so its graph is authoritative: replace the
# observation/relation set so rows an edit removed are dropped and rows it
# added appear immediately, not after a later reindex (issue #1076).
await replace_accepted_note_graph(
session,
entity=entity,
prepared=prepared,
self_relation_resolver=preparer,
repositories=dependencies.write_repositories,
)
return plan_accepted_note_write_change(
status_code=200,
entity=entity,
Expand Down
115 changes: 113 additions & 2 deletions src/basic_memory/indexing/accepted_note_write_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
Expand All @@ -17,7 +17,11 @@
build_accepted_note_search_row,
)
from basic_memory.models import Entity, NoteContent
from basic_memory.repository import AcceptedNoteContentWrite
from basic_memory.repository import (
AcceptedNoteContentWrite,
AcceptedObservationWrite,
AcceptedRelationWrite,
)
from basic_memory.repository.entity_repository import (
AcceptedPendingEntityWrite,
EntityMetadata,
Expand Down Expand Up @@ -80,6 +84,12 @@ def markdown_content(self) -> str: ...
@property
def search_content(self) -> str: ...

@property
def observations(self) -> Sequence[AcceptedObservationWrite]: ...

@property
def relations(self) -> Sequence[AcceptedRelationWrite]: ...


class AcceptedPreparedEntityTarget(Protocol):
"""Mutable entity fields mirrored from one prepared accepted note."""
Expand Down Expand Up @@ -144,6 +154,17 @@ async def prepare_edit_entity_content(
) -> AcceptedPreparedMarkdownWriteSource: ...


class AcceptedNoteSelfRelationResolver(Protocol):
"""Capability for resolving ambiguity-safe self-links during acceptance."""

async def resolve_deferred_self_relation(
self,
target: str,
entity: Entity,
session: AsyncSession | None = ...,
) -> Entity | None: ...


class AcceptedPreparedMoveSource(Protocol):
"""Prepared accepted markdown and permalink state for a note move."""

Expand Down Expand Up @@ -261,6 +282,28 @@ async def delete_entity_vectors(
) -> None: ...


class AcceptedNoteObservationRepository(Protocol):
"""Repository capability for replacing one accepted note's observations."""

async def replace_accepted_observations(
self,
session: AsyncSession,
entity_id: RuntimeEntityId,
observations: Sequence[AcceptedObservationWrite],
) -> None: ...


class AcceptedNoteRelationRepository(Protocol):
"""Repository capability for replacing one accepted note's outgoing relations."""

async def replace_accepted_outgoing_relations(
self,
session: AsyncSession,
entity_id: RuntimeEntityId,
relations: Sequence[AcceptedRelationWrite],
) -> None: ...


class AcceptedNoteWriteRepositories(Protocol):
"""Repository capability set needed by accepted-note DB-first writes."""

Expand All @@ -279,6 +322,16 @@ def search_repository(
project_id: ProjectId,
) -> AcceptedNoteSearchRowRepository: ...

def observation_repository(
self,
project_id: ProjectId,
) -> AcceptedNoteObservationRepository: ...

def relation_repository(
self,
project_id: ProjectId,
) -> AcceptedNoteRelationRepository: ...


@dataclass(frozen=True, slots=True)
class AcceptedPreparedNoteWrite:
Expand Down Expand Up @@ -659,6 +712,64 @@ async def persist_accepted_note_write(
)


async def replace_accepted_note_graph(
session: AsyncSession,
*,
entity: Entity,
prepared: AcceptedPreparedMarkdownWriteSource,
self_relation_resolver: AcceptedNoteSelfRelationResolver,
repositories: AcceptedNoteWriteRepositories,
) -> None:
"""Persist the accepted note's observations and relations in one transaction.

The accepted markdown was already parsed during prepare, so the graph rows
are committed alongside note_content and search instead of waiting for a
later ``index_file`` pass to reparse the materialized file. Without this the
observation/relation tables stay empty after a successful DB-first write, so
schema inference and relation traversal are nondeterministic until an
unrelated storage notification happens to fire (issue #1076).
"""
observation_repository = repositories.observation_repository(entity.project_id)
await observation_repository.replace_accepted_observations(
session,
entity.id,
prepared.observations,
)

# General deferred resolution skips target_id == from_id to avoid binding an
# ambiguous title to the wrong note. Reuse the indexing path's narrow,
# ambiguity-safe self resolver here so filepath/permalink self-links do not
# remain unresolved forever after a DB-first write.
relations: list[AcceptedRelationWrite] = []
for relation in prepared.relations:
if relation.target_id is not None:
relations.append(relation)
continue
target_entity = await self_relation_resolver.resolve_deferred_self_relation(
relation.target_name,
entity,
session=session,
)
if target_entity is None:
relations.append(relation)
continue
relations.append(
AcceptedRelationWrite(
relation_type=relation.relation_type,
target_name=target_entity.title,
context=relation.context,
target_id=target_entity.id,
)
)

relation_repository = repositories.relation_repository(entity.project_id)
await relation_repository.replace_accepted_outgoing_relations(
session,
entity.id,
relations,
)


async def delete_accepted_note_entity(
session: AsyncSession,
*,
Expand Down
6 changes: 4 additions & 2 deletions src/basic_memory/repository/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@
NoteContentRepository,
NoteContentVersionConflict,
)
from .observation_repository import ObservationRepository
from .observation_repository import AcceptedObservationWrite, ObservationRepository
from .project_repository import ProjectRepository
from .relation_repository import RelationRepository
from .relation_repository import AcceptedRelationWrite, RelationRepository

__all__ = [
"EntityRepository",
"AcceptedNoteContentWrite",
"NoteContentRepository",
"NoteContentVersionConflict",
"AcceptedObservationWrite",
"ObservationRepository",
"ProjectRepository",
"AcceptedRelationWrite",
"RelationRepository",
]
46 changes: 46 additions & 0 deletions src/basic_memory/repository/observation_repository.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Repository for managing Observation objects."""

from dataclasses import dataclass
from typing import Dict, List, Sequence

from sqlalchemy import select
Expand All @@ -11,6 +12,21 @@
from basic_memory.repository.repository import Repository


@dataclass(frozen=True, slots=True)
class AcceptedObservationWrite:
"""One observation parsed from accepted markdown, ready to persist.

Mirrors the markdown ``Observation`` fields so the accepted-write path can
persist the graph without constructing ORM rows in the storage-neutral
runner (issue #1076).
"""

content: str
category: str | None
context: str | None
tags: list[str] | None


class ObservationRepository(Repository[Observation]):
"""Repository for Observation model with memory-specific operations."""

Expand Down Expand Up @@ -78,3 +94,33 @@ async def find_by_entities(
observations_by_entity[obs.entity_id].append(obs)

return observations_by_entity

async def replace_accepted_observations(
self,
session: AsyncSession,
entity_id: int,
observations: Sequence[AcceptedObservationWrite],
) -> None:
"""Replace an entity's observations with the accepted markdown set.

Observations are owned by the markdown file, so an accepted write
replaces the prior set rather than merging — the same delete-then-insert
semantics ``EntityService.update_entity_and_observations`` uses for the
file-indexing path. Runs inside the caller's transaction so the graph
commits atomically with the note_content and search rows (issue #1076).
"""
await self.delete_by_fields(session, entity_id=entity_id)
if not observations:
return
rows = [
Observation(
project_id=self.project_id,
entity_id=entity_id,
content=obs.content,
category=obs.category,
Comment thread
phernandez marked this conversation as resolved.
context=obs.context,
tags=obs.tags,
)
for obs in observations
]
await self.add_all_no_return(session, rows)
Loading
Loading