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: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ Entries that change an on-disk format or a response shape say so.

## [Unreleased]

### Added
- Python/TypeScript SDKs: `promote`/`analyze_communities`/`embeddings_status`
— the last three HTTP endpoints with no client-side coverage
(`POST /contexts/{name}/promote`, `GET /contexts/{name}/communities`,
`GET /contexts/{name}/embeddings`), all three already documented in
`src/llm-protocol.md` but missing from `sdk/spec/surface.yaml` and
both SDKs (issue #625).

### Changed
- `ApiError` gains an additive `issues_total: usize` field (present only
when nonzero) — the failure-side counterpart to
Expand Down
8 changes: 8 additions & 0 deletions sdk/python/src/taguru/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@
CrossPassagePage,
DirectoryEntry,
DriftAudit,
EmbeddingsGlossesStatus,
EmbeddingsPassagesStatus,
EmbeddingsStatus,
EvidenceItem,
EvidenceLanesPlan,
EvidencePackage,
Expand Down Expand Up @@ -103,6 +106,7 @@
PassageLookup,
PassagePage,
PathsPage,
PromoteOutcome,
RankingExplain,
Recollection,
RefreshBreakdown,
Expand Down Expand Up @@ -234,6 +238,9 @@
"CrossPassagePage",
"DirectoryEntry",
"DriftAudit",
"EmbeddingsGlossesStatus",
"EmbeddingsPassagesStatus",
"EmbeddingsStatus",
"EvidenceItem",
"EvidenceLanesPlan",
"EvidencePackage",
Expand Down Expand Up @@ -264,6 +271,7 @@
"PassageLookup",
"PassagePage",
"PathsPage",
"PromoteOutcome",
"RankingExplain",
"Recollection",
"RefreshBreakdown",
Expand Down
58 changes: 58 additions & 0 deletions sdk/python/src/taguru/_async/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
CrossPassagePage,
DirectoryEntry,
DriftAudit,
EmbeddingsStatus,
EvidencePackage,
ExplorePage,
GroupEntry,
Expand All @@ -58,6 +59,7 @@
PassageLookup,
PassagePage,
PathsPage,
PromoteOutcome,
RefreshOutcome,
ResolveExplanation,
RetractAssociationOutcome,
Expand Down Expand Up @@ -1356,6 +1358,18 @@ async def search_communities(
result = await self._post("/communities/search", body)
return decode(CommunityPage, result) # type: ignore[no-any-return]

async def analyze_communities(self) -> str:
"""Community detection on the live graph (NDJSON text: a header
line, then one line per community, leaves first).

Compute-heavy (heavy-ops gated) — most callers want
:meth:`search_communities` instead, which serves a pre-built
summary artifact. This is the derivation half ``taguru
communities`` itself orchestrates.
"""
response = await self._client._send("GET", self._path + "/communities")
return response.text

async def explain_search_passages(
self,
query: str,
Expand Down Expand Up @@ -1590,11 +1604,55 @@ async def refresh_embeddings(self) -> RefreshOutcome:
result = await self._post("/embeddings/refresh")
return decode(RefreshOutcome, result) # type: ignore[no-any-return]

async def embeddings_status(self) -> EmbeddingsStatus:
"""The embedding identity: the provider configured now beside
the (model, width) each vector sidecar was actually built with.

``provider_model`` is ``None`` when embeddings are off; a
missing lane means nothing of that kind has been embedded yet
(the two models disagreeing means a refresh is needed).
"""
result = await self._client._request_json("GET", self._path + "/embeddings")
return decode(EmbeddingsStatus, result) # type: ignore[no-any-return]

async def compact(self) -> CompactOutcome:
"""Rebuild the image without dead records (admin role)."""
result = await self._post("/compact")
return decode(CompactOutcome, result) # type: ignore[no-any-return]

# -- promotion -----------------------------------------------------------------

async def promote(
self,
into: str,
sources: Sequence[str],
*,
audit: bool | None = None,
dry_run: bool = False,
) -> PromoteOutcome:
"""Move named scratch sources whole into the established
context ``into`` (ADR 0018) — the export/import round trip in
one call, without re-extraction.

Each source moves whole (passage, date, tags, only its own
share of every edge's weight); source ids survive, and
applying is per-source retract-then-apply — re-promoting is
idempotent. ``into`` must already exist (never created here).
A named source missing from this (scratch) context refuses the
WHOLE request. ``audit`` omitted means ``True``: after a real
apply, the destination gets the default consolidation audit
(all three checks) riding back as candidates, never applied.
``dry_run=True`` previews the same ``batches`` shape and
writes nothing.
"""
result = await self._client._request_json(
"POST",
self._path + "/promote",
params=drop_none({"dry_run": True if dry_run else None}),
json_body=drop_none({"into": into, "sources": list(sources), "audit": audit}),
)
return decode(PromoteOutcome, result) # type: ignore[no-any-return]

# -- export ------------------------------------------------------------------------

async def export(self) -> str:
Expand Down
55 changes: 55 additions & 0 deletions sdk/python/src/taguru/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any

__all__ = [
"LabelUsage",
Expand Down Expand Up @@ -65,12 +66,16 @@
"RetractAssociationOutcome",
"RefreshBreakdown",
"RefreshOutcome",
"EmbeddingsGlossesStatus",
"EmbeddingsPassagesStatus",
"EmbeddingsStatus",
"TwinPair",
"VocabularyAudit",
"UnsourcedEdge",
"DriftAudit",
"CompactOutcome",
"ImportOutcome",
"PromoteOutcome",
"BatchApplyResult",
"RetrievalResult",
]
Expand Down Expand Up @@ -867,6 +872,38 @@ class RefreshOutcome:
passages: RefreshBreakdown | None = None


@dataclass(slots=True, frozen=True)
class EmbeddingsGlossesStatus:
"""The gloss vector sidecar's identity and size."""

model: str
width: int
concepts: int
labels: int


@dataclass(slots=True, frozen=True)
class EmbeddingsPassagesStatus:
"""The passage vector sidecar's identity and size."""

model: str
width: int
rows: int


@dataclass(slots=True, frozen=True)
class EmbeddingsStatus:
"""``GET /contexts/{name}/embeddings``: the provider configured now
beside the (model, width) each vector sidecar was actually built
with. ``provider_model`` is ``None`` when embeddings are off; a
missing lane means nothing of that kind has been embedded yet.
"""

provider_model: str | None
glosses: EmbeddingsGlossesStatus | None = None
passages: EmbeddingsPassagesStatus | None = None


@dataclass(slots=True, frozen=True)
class TwinPair:
a: str
Expand Down Expand Up @@ -1021,6 +1058,24 @@ class ImportResult:
schema_violations: int = 0


@dataclass(slots=True, frozen=True)
class PromoteOutcome:
"""What ``POST /contexts/{name}/promote`` accomplished (ADR 0018):
each named source moved whole from this (scratch) context into
``into``, ``/import``'s own per-batch outcome shape.

``audit`` mirrors :meth:`AsyncContext.audit_consolidation`'s own
untyped shape (a ``ConsolidationAudit`` server-side) — absent on a
dry run, on ``audit=False``, and when the audit itself could not
run (``audit_skipped`` then says why).
"""

batches: list[ImportOutcome]
aliases_dropped: int
audit: dict[str, Any] | None = None
audit_skipped: str | None = None


@dataclass(slots=True, frozen=True)
class BatchApplyResult:
"""Outcome of ``add_associations_batched``: chunks are independent writes.
Expand Down
58 changes: 58 additions & 0 deletions sdk/python/src/taguru/_sync/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
CrossPassagePage,
DirectoryEntry,
DriftAudit,
EmbeddingsStatus,
EvidencePackage,
ExplorePage,
GroupEntry,
Expand All @@ -52,6 +53,7 @@
PassageLookup,
PassagePage,
PathsPage,
PromoteOutcome,
RefreshOutcome,
ResolveExplanation,
RetractAssociationOutcome,
Expand Down Expand Up @@ -1344,6 +1346,18 @@ def search_communities(
result = self._post("/communities/search", body)
return decode(CommunityPage, result) # type: ignore[no-any-return]

def analyze_communities(self) -> str:
"""Community detection on the live graph (NDJSON text: a header
line, then one line per community, leaves first).

Compute-heavy (heavy-ops gated) — most callers want
:meth:`search_communities` instead, which serves a pre-built
summary artifact. This is the derivation half ``taguru
communities`` itself orchestrates.
"""
response = self._client._send("GET", self._path + "/communities")
return response.text

def explain_search_passages(
self,
query: str,
Expand Down Expand Up @@ -1576,11 +1590,55 @@ def refresh_embeddings(self) -> RefreshOutcome:
result = self._post("/embeddings/refresh")
return decode(RefreshOutcome, result) # type: ignore[no-any-return]

def embeddings_status(self) -> EmbeddingsStatus:
"""The embedding identity: the provider configured now beside
the (model, width) each vector sidecar was actually built with.

``provider_model`` is ``None`` when embeddings are off; a
missing lane means nothing of that kind has been embedded yet
(the two models disagreeing means a refresh is needed).
"""
result = self._client._request_json("GET", self._path + "/embeddings")
return decode(EmbeddingsStatus, result) # type: ignore[no-any-return]

def compact(self) -> CompactOutcome:
"""Rebuild the image without dead records (admin role)."""
result = self._post("/compact")
return decode(CompactOutcome, result) # type: ignore[no-any-return]

# -- promotion -----------------------------------------------------------------

def promote(
self,
into: str,
sources: Sequence[str],
*,
audit: bool | None = None,
dry_run: bool = False,
) -> PromoteOutcome:
"""Move named scratch sources whole into the established
context ``into`` (ADR 0018) — the export/import round trip in
one call, without re-extraction.

Each source moves whole (passage, date, tags, only its own
share of every edge's weight); source ids survive, and
applying is per-source retract-then-apply — re-promoting is
idempotent. ``into`` must already exist (never created here).
A named source missing from this (scratch) context refuses the
WHOLE request. ``audit`` omitted means ``True``: after a real
apply, the destination gets the default consolidation audit
(all three checks) riding back as candidates, never applied.
``dry_run=True`` previews the same ``batches`` shape and
writes nothing.
"""
result = self._client._request_json(
"POST",
self._path + "/promote",
params=drop_none({"dry_run": True if dry_run else None}),
json_body=drop_none({"into": into, "sources": list(sources), "audit": audit}),
)
return decode(PromoteOutcome, result) # type: ignore[no-any-return]

# -- export ------------------------------------------------------------------------

def export(self) -> str:
Expand Down
48 changes: 48 additions & 0 deletions sdk/python/tests/integration/test_full_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,15 @@ def test_embeddings_refresh_501_without_provider(client: Taguru, fresh_name: str
client.contexts.delete(fresh_name)


def test_embeddings_status_reports_no_provider_configured(client: Taguru, fresh_name: str) -> None:
client.contexts.create(fresh_name)
status = client.context(fresh_name).embeddings_status()
assert status.provider_model is None
assert status.glosses is None
assert status.passages is None
client.contexts.delete(fresh_name)


def test_vocabulary_audit_surfaces_lexical_twins(client: Taguru, fresh_name: str) -> None:
client.contexts.create(fresh_name)
ctx = client.context(fresh_name)
Expand Down Expand Up @@ -415,6 +424,29 @@ def test_compact_reports_shed_bytes(client: Taguru, fresh_name: str) -> None:
client.contexts.delete(fresh_name)


def test_promote_moves_a_source_and_previews_with_dry_run(client: Taguru, fresh_name: str) -> None:
destination = f"{fresh_name}-dest"
seed(client, fresh_name)
client.contexts.create(destination)
scratch = client.context(fresh_name)

preview = scratch.promote(destination, ["docs/aomine.md"], dry_run=True)
assert len(preview.batches) == 1
assert preview.audit is None
# A dry run writes nothing.
assert client.context(destination).list_sources().total == 0

outcome = scratch.promote(destination, ["docs/aomine.md"])
assert outcome.batches[0].source == "docs/aomine.md"
assert outcome.batches[0].context == destination
assert outcome.audit is not None
assert outcome.audit["detector"] == "consolidation/1"
assert client.context(destination).list_sources().total == 1

client.contexts.delete(destination)
client.contexts.delete(fresh_name)


def test_flush_names_dirty_contexts(client: Taguru, fresh_name: str) -> None:
seed(client, fresh_name)
flushed = client.flush()
Expand Down Expand Up @@ -487,6 +519,22 @@ def test_search_communities_verdicts_staleness_over_an_artifact(
client.contexts.delete(fresh_name)


def test_analyze_communities_returns_ndjson_with_a_header_line(
client: Taguru, fresh_name: str
) -> None:
seed(client, fresh_name)
ctx = client.context(fresh_name)

body = ctx.analyze_communities()
lines = body.splitlines()
assert lines
header = json.loads(lines[0])
assert header["taguru_communities"] == 1
assert header["context"] == fresh_name

client.contexts.delete(fresh_name)


def test_retrieve_end_to_end(client: Taguru, fresh_name: str) -> None:
seed(client, fresh_name)
ctx = client.context(fresh_name)
Expand Down
Loading