Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed59d891b2 |
No files matched your search
@@ -1,6 +1,7 @@
|
||||
"""Authenticated SDK context search route, without generation or reindex side effects."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC, datetime
|
||||
@@ -36,6 +37,8 @@ from one_memory.semantic_deadline import SemanticMemoryHit
|
||||
from one_memory.user_scope import owns
|
||||
|
||||
CALL_EVENT_RESPONSE_BUDGET_MS = 250
|
||||
SDK_CONTEXT_CONTENT_LIMIT = 20_000
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Pass-through filters the raw lane honours. Scene, layers, reader and the
|
||||
#: schema/working floors describe memory lanes; the raw lane is always the
|
||||
@@ -90,6 +93,13 @@ def hit_namespace(hit: SemanticMemoryHit) -> dict[str, str]:
|
||||
return {}
|
||||
|
||||
|
||||
def bounded_context_content(content: str) -> tuple[str, bool]:
|
||||
"""Fit SDK text into its wire contract before Pydantic validates it."""
|
||||
if len(content) <= SDK_CONTEXT_CONTENT_LIMIT:
|
||||
return content, False
|
||||
return content[:SDK_CONTEXT_CONTENT_LIMIT], True
|
||||
|
||||
|
||||
def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
"""SDK-native context: no Evidence/Governance ids or fabricated revisions."""
|
||||
router = APIRouter(prefix="/api/v2/context", dependencies=dependencies)
|
||||
@@ -170,34 +180,39 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
)
|
||||
turns = raw_display_turns(hit) if value.raw_unit == "turn" else ()
|
||||
if not turns:
|
||||
content, truncated = bounded_context_content(hit.content)
|
||||
primary.append(
|
||||
SdkRawContextItem(
|
||||
raw_id=hit.raw_id,
|
||||
content=hit.content,
|
||||
content=content,
|
||||
source=hit.source,
|
||||
session_id=hit.session_id,
|
||||
memory_at=memory_at,
|
||||
score=hit.score,
|
||||
truncated=truncated,
|
||||
)
|
||||
)
|
||||
continue
|
||||
# The embedding is the batch's: the hit is shown by the turns
|
||||
# that contain the query, or else by its closest turn, each
|
||||
# with the hit's score.
|
||||
shown = [
|
||||
SdkRawContextItem(
|
||||
raw_id=hit.raw_id,
|
||||
turn_id=turn.turn_id,
|
||||
turn_index=turn.turn_index,
|
||||
turn_count=turn.turn_count,
|
||||
content=turn.content,
|
||||
source=hit.source,
|
||||
session_id=hit.session_id,
|
||||
memory_at=memory_at,
|
||||
score=hit.score,
|
||||
shown: list[SdkRawContextItem] = []
|
||||
for turn in turns_for_hit(turns, value.query):
|
||||
content, truncated = bounded_context_content(turn.content)
|
||||
shown.append(
|
||||
SdkRawContextItem(
|
||||
raw_id=hit.raw_id,
|
||||
turn_id=turn.turn_id,
|
||||
turn_index=turn.turn_index,
|
||||
turn_count=turn.turn_count,
|
||||
content=content,
|
||||
source=hit.source,
|
||||
session_id=hit.session_id,
|
||||
memory_at=memory_at,
|
||||
score=hit.score,
|
||||
truncated=truncated,
|
||||
)
|
||||
)
|
||||
for turn in turns_for_hit(turns, value.query)
|
||||
]
|
||||
primary.append(shown[0])
|
||||
siblings.extend(shown[1:])
|
||||
chosen = primary[: value.limit]
|
||||
@@ -250,16 +265,15 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
or not owns(memory.user_id, value.user_id)
|
||||
):
|
||||
continue
|
||||
content, truncated = bounded_context_content(
|
||||
hit.formatted_content
|
||||
if hit.sdk_formatted and hit.formatting_complete and hit.formatted_content
|
||||
else memory.content
|
||||
)
|
||||
items.append(
|
||||
SdkMemoryContextItem(
|
||||
memory_id=memory.memory_id,
|
||||
content=(
|
||||
hit.formatted_content
|
||||
if hit.sdk_formatted
|
||||
and hit.formatting_complete
|
||||
and hit.formatted_content
|
||||
else memory.content
|
||||
),
|
||||
content=content,
|
||||
layer=memory.layer,
|
||||
status=memory.status,
|
||||
source=memory.source,
|
||||
@@ -269,6 +283,7 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
else None
|
||||
),
|
||||
score=hit.score,
|
||||
truncated=truncated,
|
||||
)
|
||||
)
|
||||
return tuple(items)
|
||||
@@ -318,7 +333,23 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
if isinstance(lane, EmbeddingUnavailableError):
|
||||
# No lexical fallback: the query could not be embedded.
|
||||
raise HTTPException(503, EMBEDDING_UNAVAILABLE)
|
||||
partial = isinstance(raw_items, Exception) or isinstance(memory_items, Exception)
|
||||
requested_lanes = {
|
||||
"raw": raw_items,
|
||||
"memory": memory_items,
|
||||
}
|
||||
failed_lanes = {
|
||||
name: lane
|
||||
for name, lane in requested_lanes.items()
|
||||
if name in value.kinds and isinstance(lane, Exception)
|
||||
}
|
||||
for name, error in failed_lanes.items():
|
||||
logger.warning(
|
||||
"sdk_context_lane_failed lane=%s error_class=%s",
|
||||
name,
|
||||
type(error).__name__,
|
||||
)
|
||||
all_requested_failed = len(failed_lanes) == len(value.kinds)
|
||||
partial = bool(failed_lanes)
|
||||
raw_candidates = (
|
||||
() if isinstance(raw_items, Exception) else cast(tuple[SdkContextItem, ...], raw_items)
|
||||
)
|
||||
@@ -347,11 +378,20 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
if len(content) > remaining:
|
||||
item = item.model_copy(update={"content": content[:remaining], "truncated": True})
|
||||
truncated = True
|
||||
truncated = truncated or item.truncated
|
||||
selected.append(item)
|
||||
used += len(item.content)
|
||||
final = SdkContextSearchResponse(
|
||||
request_id=str(uuid4()),
|
||||
state=("partial" if partial else "grounded" if selected else "empty"),
|
||||
state=(
|
||||
"unavailable"
|
||||
if all_requested_failed
|
||||
else "partial"
|
||||
if partial
|
||||
else "grounded"
|
||||
if selected
|
||||
else "empty"
|
||||
),
|
||||
items=tuple(selected),
|
||||
used_characters=used,
|
||||
truncated=truncated,
|
||||
|
||||
@@ -469,6 +469,101 @@ def test_lite_raw_search_is_plain_rag_over_active_and_shadow_raws(tmp_path, monk
|
||||
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_long_semantic_raw_hit_cannot_erase_an_earlier_match(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, sdk, client):
|
||||
app.state.memory_mode = lambda: "lite"
|
||||
|
||||
def search(query, **kwargs):
|
||||
sdk._record("search", {"query": query, **kwargs})
|
||||
memories = [
|
||||
{
|
||||
"memory_id": "raw-target",
|
||||
"content": "[user]: lite 模式的 3d 图需要调整",
|
||||
"layer": "l1_raw",
|
||||
"status": "active",
|
||||
"user_id": "local",
|
||||
"agent_id": "all-apps",
|
||||
"session_id": "target-session",
|
||||
"source": "cursor",
|
||||
"memory_at": 1_790_100_000,
|
||||
"score": 0.97,
|
||||
},
|
||||
{
|
||||
"memory_id": "raw-long",
|
||||
"content": f"[user]: {'x' * 20_001}",
|
||||
"layer": "l1_raw",
|
||||
"status": "active",
|
||||
"user_id": "local",
|
||||
"agent_id": "all-apps",
|
||||
"session_id": "long-session",
|
||||
"source": "cursor",
|
||||
"memory_at": 1_790_000_000,
|
||||
"score": 0.9,
|
||||
},
|
||||
]
|
||||
return {
|
||||
"request_id": "long-raw",
|
||||
"memories": memories[1:] if query == "oversized" else memories,
|
||||
}
|
||||
|
||||
sdk.search = search
|
||||
response = await client.post(
|
||||
"/api/v2/context/search",
|
||||
json={
|
||||
"query": "3d",
|
||||
"kinds": ["raw"],
|
||||
"raw_unit": "turn",
|
||||
"limit": 20,
|
||||
"character_budget": 20_000,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["state"] == "grounded"
|
||||
assert body["items"][0]["raw_id"] == "raw-target"
|
||||
assert "3d" in body["items"][0]["content"]
|
||||
assert body["items"][1]["raw_id"] == "raw-long"
|
||||
assert body["items"][1]["truncated"] is True
|
||||
assert body["truncated"] is True
|
||||
|
||||
single = await client.post(
|
||||
"/api/v2/context/search",
|
||||
json={
|
||||
"query": "oversized",
|
||||
"kinds": ["raw"],
|
||||
"raw_unit": "turn",
|
||||
"character_budget": 20_000,
|
||||
},
|
||||
)
|
||||
assert single.json()["items"][0]["truncated"] is True
|
||||
assert single.json()["truncated"] is True
|
||||
|
||||
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_raw_only_lane_failure_is_unavailable_not_an_empty_partial(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, sdk, client):
|
||||
app.state.memory_mode = lambda: "lite"
|
||||
|
||||
def search(_query, **_kwargs):
|
||||
raise ValueError("private conversation text must not reach logs")
|
||||
|
||||
sdk.search = search
|
||||
response = await client.post(
|
||||
"/api/v2/context/search",
|
||||
json={"query": "3d", "kinds": ["raw"], "raw_unit": "turn"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["state"] == "unavailable"
|
||||
assert response.json()["items"] == []
|
||||
|
||||
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_every_recall_and_ask_option_reaches_the_sdk(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, sdk, client):
|
||||
|
||||
@@ -41,7 +41,7 @@ it("lists raw conversations for the selected application", async () => {
|
||||
});
|
||||
|
||||
it("keeps the application filter when searching raw conversations", async () => {
|
||||
post.mockResolvedValue({ data: { items: [] } });
|
||||
post.mockResolvedValue({ data: { state: "empty", items: [] } });
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
await searchSdkRaw("release plan", { source: "claude-code", signal });
|
||||
@@ -60,6 +60,14 @@ it("keeps the application filter when searching raw conversations", async () =>
|
||||
);
|
||||
});
|
||||
|
||||
it("does not present an unavailable raw search as no matches", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: { state: "unavailable", items: [] },
|
||||
});
|
||||
|
||||
await expect(searchSdkRaw("3d")).rejects.toThrow("raw_search_unavailable");
|
||||
});
|
||||
|
||||
it("pages the independent lightweight raw graph", async () => {
|
||||
get.mockResolvedValue({
|
||||
data: {
|
||||
|
||||
@@ -1052,6 +1052,8 @@ export async function searchSdkRaw(
|
||||
},
|
||||
{ signal: options.signal, timeout: 10_000 },
|
||||
);
|
||||
if (response.data?.state !== "grounded" && response.data?.state !== "empty")
|
||||
throw new Error("raw_search_unavailable");
|
||||
const items = response.data?.items;
|
||||
return Array.isArray(items)
|
||||
? items.filter(
|
||||
|
||||
Reference in new issue
Block a user