Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@
"files": {
"backend/routers/apps.py": 2335,
"backend/routers/chat.py": 1588,
"backend/routers/developer.py": 2202,
"backend/routers/developer.py": 2282,
"backend/routers/mcp_sse.py": 1858,
"backend/routers/sync.py": 2058,
"backend/routers/users.py": 2046
},
"raise_justifications": {
"backend/routers/chat.py": "Merging the Windows-port chat surface with main combined the stream-error fallback (answered guard) with mains journey/attempt observability in the same SSE generators; +11 lines of combined guard flow, no new route.",
"backend/routers/developer.py": "From-segments processing now wraps process_conversation in the admission lease guard so the crash-orphan sweep cannot terminalize active work (#10461); +7 lines for the conditional guard with rollback_on_failure=False.",
"backend/routers/developer.py": "GET /v1/dev/user/memories serves the authoritative legacy memories collection for legacy-cohort accounts with no rollout state (#9892), keeping the narrow deny/fallback decision in the route that owns the read contract. +80 for POST /v1/dev/user/ask, which grounds a natural-language answer in the caller's conversations (search + qa_rag) beside the conversation routes that own that read contract, behind the dedicated dev:ask per-key rate limit.",
"backend/routers/mcp_sse.py": "MCP SSE memory reads route their legacy-fallback decision through the shared mcp_legacy_read_authorized helper (#9892); one import line.",
"backend/routers/sync.py": "Fresh Sync's daily ceiling remains beside the existing fresh-admission gates, before staged audio or durable worker work is created. Lifecycle-fenced workers are terminalized at this Cloud Tasks boundary so released WAL clients do not retry an owner they no longer own; +2 for the queued-dispatch-lost stale finalization path (#10033).",
"backend/routers/users.py": "GET /v1/users/subscription serializes the new mobile plus/max plans as `unlimited` for clients whose plan enum predates them, so day-one buyers read as paid instead of Free (mirrors the existing operator remap); real limits/grandfather are computed from the true plan first."
Expand Down
15 changes: 15 additions & 0 deletions backend/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,21 @@ async def get_uid_with_conversations_read(auth: ApiKeyAuth = Depends(get_api_key
return auth.uid


async def get_uid_with_conversations_read_ask(
auth: ApiKeyAuth = Depends(get_api_key_auth),
request: Request = None,
) -> str:
"""conversations:read plus the tighter dev:ask budget for the billable RAG endpoint.

POST /v1/dev/user/ask invokes an LLM (qa_rag) per call, so it carries its own low
per-key hourly cap rather than riding the cheap dev:conversations_read list limit —
a leaked or overused key can't turn it into an unbounded billable endpoint.
"""
_require_conversations_read_scope(auth)
await _check_dev_api_key_rate_limit_async(request=request, auth=auth, policy_name="dev:ask")
return auth.uid


def check_conversation_transcript_read_limit(
auth: ApiKeyAuth,
request: Optional[Request] = None,
Expand Down
25 changes: 25 additions & 0 deletions backend/route_policy_manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,31 @@
schema_version: 1
service: backend-main
routes:
- route_type: http
method: POST
path: /v1/dev/user/ask
policy:
review_status: reviewed
auth:
mechanisms:
- developer_api_key
- firebase_id_token
placement: dependency
scopes:
- conversations:read
byok: not_applicable
rate_limit:
policy_name: dev:ask
key_subject: api_key
enforcement: fail_closed
placement: dependency
timeout_class: default_method
surface: developer_api
visibility: public_documented
data_domain: conversations
deprecation:
state: active
owner: backend
- route_type: http
method: POST
path: /v1/conversations/shared/chat
Expand Down
80 changes: 80 additions & 0 deletions backend/routers/developer.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import uuid

Check warning on line 1 in backend/routers/developer.py

View workflow job for this annotation

GitHub Actions / Desktop Swift Static & Test Contracts

Large changed file

backend/routers/developer.py is 2282 lines; consider splitting files over 800 lines.

Check warning on line 1 in backend/routers/developer.py

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

backend/routers/developer.py is 2282 lines; consider splitting files over 800 lines.
from datetime import datetime, timezone, timedelta

from enum import Enum
Expand Down Expand Up @@ -42,6 +42,7 @@
get_auth_with_conversation_detail_read,
get_auth_with_conversations_read,
get_uid_with_conversations_read,
get_uid_with_conversations_read_ask,
get_uid_with_conversations_write,
get_developer_memory_default_memory_batch_write_context,
get_developer_memory_default_memory_read_context,
Expand All @@ -58,6 +59,9 @@
from utils.conversations.process_conversation import process_conversation
from utils.conversations import lifecycle as lifecycle_service
from utils.conversations.location import resolve_geolocation
from utils.conversations.search import search_conversations
from utils.conversations.factory import deserialize_conversations
from utils.llm.chat import qa_rag
from utils.executors import postprocess_executor
from utils.request_validation import HistoryDays
from utils.llm.memories import identify_category_for_memory
Expand Down Expand Up @@ -1324,6 +1328,82 @@
return folders_db.get_folders(uid)


class DeveloperAskRequest(BaseModel):
question: str = Field(
min_length=1, max_length=1000, description="A natural-language question about the user's life/conversations"
)
limit: int = Field(
default=5, ge=1, le=10, description="How many of the most relevant conversations to ground the answer on"
)
timezone: str = Field(default="UTC", description="IANA timezone used to resolve relative dates in the answer")


class DeveloperAskSource(BaseModel):
id: str
title: str
created_at: Optional[datetime] = None


class DeveloperAskResponse(BaseModel):
answer: str
sources: List[DeveloperAskSource]


_ASK_NO_CONTEXT = "I couldn't find any of your conversations relevant to that question."


def _ask_context_from_conversations(conversations: List[Conversation]) -> str:
blocks: List[str] = []
for c in conversations:
title = ((c.structured.title if c.structured else None) or "Untitled").strip()
overview = ((c.structured.overview if c.structured else None) or "").strip()
transcript = " ".join((getattr(s, "text", "") or "") for s in c.transcript_segments).strip()[:3000]
date = c.created_at.date().isoformat() if c.created_at else ""
blocks.append(f'Conversation "{title}" ({date})\nSummary: {overview}\nTranscript: {transcript}')
return "\n\n---\n\n".join(blocks)


@router.post(
"/v1/dev/user/ask",
response_model=DeveloperAskResponse,
tags=["Conversations"],
operation_id="ask",
)
def ask_conversations(request: DeveloperAskRequest, uid: str = Depends(get_uid_with_conversations_read_ask)):
"""
Answer a natural-language question grounded in the user's own conversations.

Semantically searches the user's conversations for the question, then synthesizes a
cited answer from the most relevant ones — the same retrieval + RAG the chat surface
uses, exposed for headless / Developer-API callers (CLI, CI, scripts). Read-only:
it never writes, and locked conversations are excluded by the search layer.
"""
question = request.question.strip()
if not question:
raise HTTPException(status_code=400, detail="question must not be empty")

results = search_conversations(uid, question, per_page=request.limit)
items = results.get("items", []) if isinstance(results, dict) else []
conversation_ids = [item["id"] for item in items if item.get("id")][: request.limit]
if not conversation_ids:
return DeveloperAskResponse(answer=_ASK_NO_CONTEXT, sources=[])

conversations = deserialize_conversations(conversations_db.get_conversations_by_id(uid, conversation_ids))
if not conversations:
return DeveloperAskResponse(answer=_ASK_NO_CONTEXT, sources=[])

answer = qa_rag(uid, question, _ask_context_from_conversations(conversations), cited=True, tz=request.timezone)
sources = [
DeveloperAskSource(
id=c.id,
title=((c.structured.title if c.structured else None) or "Untitled").strip(),
created_at=c.created_at,
)
for c in conversations
]
return DeveloperAskResponse(answer=answer, sources=sources)


@router.get(
"/v1/dev/user/conversations",
response_model=List[Conversation],
Expand Down Expand Up @@ -1599,7 +1679,7 @@
)


def _create_conversation_from_segments(

Check warning on line 1682 in backend/routers/developer.py

View workflow job for this annotation

GitHub Actions / Desktop Swift Static & Test Contracts

Long function

_create_conversation_from_segments is 167 lines; consider extracting focused helpers over 150 lines.

Check warning on line 1682 in backend/routers/developer.py

View workflow job for this annotation

GitHub Actions / Hygiene

Long function

_create_conversation_from_segments is 167 lines; consider extracting focused helpers over 150 lines.
uid: str,
request: CreateConversationFromTranscriptRequest,
*,
Expand Down
82 changes: 82 additions & 0 deletions backend/tests/unit/test_dev_ask_endpoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""The Developer-API ask endpoint answers from the user's own conversations.

POST /v1/dev/user/ask semantically searches the caller's conversations, then synthesizes
a cited answer from the most relevant ones via the same RAG the chat surface uses. It is
read-only, and must not invoke the LLM when no relevant conversation is found.
"""

import os

os.environ.setdefault("ENCRYPTION_SECRET", "omi_ZwB2ZNqB2HHpMK6wStk7sTpavJiPTFg7gXUHnc4tFABPU6pZ2c2DKgehtfgi4RZv")
os.environ.setdefault("OPENAI_API_KEY", "sk-test-not-real")

import asyncio
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import patch

import dependencies as deps
import routers.developer as dev


def _conv(cid="c1", title="Pricing sync", overview="We discussed pricing.", text="Raise prices 10%."):
return SimpleNamespace(
id=cid,
structured=SimpleNamespace(title=title, overview=overview),
transcript_segments=[SimpleNamespace(text=text)],
created_at=datetime(2026, 7, 20, tzinfo=timezone.utc),
)


def test_ask_returns_a_grounded_cited_answer():
with patch.object(dev, "search_conversations", return_value={"items": [{"id": "c1"}]}), patch.object(
dev.conversations_db, "get_conversations_by_id", return_value=[{"id": "c1"}]
), patch.object(dev, "deserialize_conversations", return_value=[_conv()]), patch.object(
dev, "qa_rag", return_value="You decided to raise prices 10%."
) as qa:
resp = dev.ask_conversations(dev.DeveloperAskRequest(question="what did I decide about pricing?"), uid="u1")

assert resp.answer == "You decided to raise prices 10%."
assert [s.id for s in resp.sources] == ["c1"]
assert resp.sources[0].title == "Pricing sync"
# Retrieval flows into qa_rag as grounded, citation-enabled context.
assert qa.call_args.kwargs["cited"] is True
assert "Raise prices 10%." in qa.call_args.args[2] # the context string


def test_ask_does_not_call_the_llm_when_no_conversation_matches():
with patch.object(dev, "search_conversations", return_value={"items": []}), patch.object(dev, "qa_rag") as qa:
resp = dev.ask_conversations(dev.DeveloperAskRequest(question="anything?"), uid="u1")

assert "couldn't find" in resp.answer.lower()
assert resp.sources == []
qa.assert_not_called() # no billable RAG call on an empty retrieval


def test_ask_endpoint_is_bound_to_the_dev_ask_rate_limited_dependency():
"""The billable LLM endpoint carries its own tight per-key budget (dev:ask), not the
cheap dev:conversations_read list limit — a leaked/overused key can't run the RAG path
unbounded. Regression for the maintainer review on #10314."""
from utils.rate_limit_config import RATE_POLICIES

# dev:ask exists and is no looser than the LLM conversation-create budget, and tighter
# than the cheap list-read limit the endpoint would otherwise have ridden.
assert "dev:ask" in RATE_POLICIES
assert RATE_POLICIES["dev:ask"][0] <= RATE_POLICIES["dev:conversations"][0]
assert RATE_POLICIES["dev:ask"][0] < RATE_POLICIES["dev:conversations_read"][0]

# The endpoint's auth dependency is the rate-limited one, and it enforces exactly the
# dev:ask policy (fail-closed per-key) before returning the uid.
enforced = {}

async def fake_enforce(*, request, auth, policy_name):
enforced["policy"] = policy_name

auth = SimpleNamespace(uid="u1", app_id="a1", key_id="k1")
with patch.object(deps, "_require_conversations_read_scope", lambda a: None), patch.object(
deps, "_check_dev_api_key_rate_limit_async", fake_enforce
):
uid = asyncio.run(deps.get_uid_with_conversations_read_ask(auth=auth, request=None))

assert uid == "u1"
assert enforced["policy"] == "dev:ask"
4 changes: 4 additions & 0 deletions backend/utils/rate_limit_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@
"dev:conversation_transcript_read": (25, 3600),
"dev:goals_read": (120, 3600),
"dev:conversations": (25, 3600),
# Ask (/v1/dev/user/ask): one qa_rag LLM call per request over the caller's
# conversations — billable like a conversation create, so it carries its own
# low per-key cap instead of riding the cheap dev:conversations_read list limit.
"dev:ask": (25, 3600),
"dev:memories": (120, 3600),
"dev:memories_batch": (15, 3600),
"dev:action_items_write": (120, 3600),
Expand Down
24 changes: 23 additions & 1 deletion desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// GENERATED CODE - DO NOT EDIT.

Check warning on line 1 in desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift

View workflow job for this annotation

GitHub Actions / Desktop Swift Static & Test Contracts

Large changed file

desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift is 14414 lines; consider splitting files over 800 lines.

Check warning on line 1 in desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift is 14414 lines; consider splitting files over 800 lines.
// Generated by backend/scripts/generate_swift_openapi_types.py from docs/api-reference/app-client-openapi.json
// Swift wire DTOs for the desktop app's backend REST surface. Domain models
// (ServerConversation, ServerMemory, Goal, ActionItem) adapt from these types;
Expand Down Expand Up @@ -7878,6 +7878,28 @@
return try JSONDecoder().decode(OmiAnyCodable.self, from: data)
}

public static func ask(client: OmiApiClient, body: OmiAnyCodable) async throws -> OmiAnyCodable {
let _path = "/v1/dev/user/ask"
guard let components = URLComponents(string: client.baseURL + _path) else {
throw OmiApiError.invalidURL
}
guard let url = components.url else { throw OmiApiError.invalidURL }
var req = URLRequest(url: url)
req.httpMethod = "POST"
for (name, value) in client.headers { req.setValue(value, forHTTPHeaderField: name) }
if let token = client.token {
req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
}
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONEncoder().encode(body)
let (data, resp) = try await URLSession.shared.data(for: req)
guard let http = resp as? HTTPURLResponse else { throw OmiApiError.invalidURL }
guard (200..<300).contains(http.statusCode) else {
throw OmiApiError.httpError(status: http.statusCode, data: data)
}
return try JSONDecoder().decode(OmiAnyCodable.self, from: data)
}

public static func listConversations(client: OmiApiClient, startDate: String? = nil, endDate: String? = nil, categories: String? = nil, limit: Int? = nil, offset: Int? = nil, includeTranscript: Bool? = nil, folderId: String? = nil, starred: Bool? = nil) async throws -> [OmiAnyCodable] {
let _path = "/v1/dev/user/conversations"
guard var components = URLComponents(string: client.baseURL + _path) else {
Expand Down Expand Up @@ -14388,5 +14410,5 @@
return try JSONDecoder().decode(OmiAnyCodable.self, from: data)
}

// Total: 385 Swift client methods generated.
// Total: 386 Swift client methods generated.
}
49 changes: 48 additions & 1 deletion desktop/windows/src/renderer/src/lib/omiApi.generated.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// GENERATED CODE - DO NOT EDIT.

Check warning on line 1 in desktop/windows/src/renderer/src/lib/omiApi.generated.ts

View workflow job for this annotation

GitHub Actions / Desktop Swift Static & Test Contracts

Large changed file

desktop/windows/src/renderer/src/lib/omiApi.generated.ts is 15738 lines; consider splitting files over 800 lines.

Check warning on line 1 in desktop/windows/src/renderer/src/lib/omiApi.generated.ts

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

desktop/windows/src/renderer/src/lib/omiApi.generated.ts is 15738 lines; consider splitting files over 800 lines.
/* eslint-disable prettier/prettier, @typescript-eslint/no-explicit-any */
// Generated by backend/scripts/generate_ts_openapi_types.py from docs/api-reference/app-client-openapi.json.

Expand Down Expand Up @@ -1410,6 +1410,23 @@
updated_at?: string | null;
}

export interface DeveloperAskRequest {
limit?: number;
question: string;
timezone?: string;
}

export interface DeveloperAskResponse {
answer: string;
sources: Array<DeveloperAskSource>;
}

export interface DeveloperAskSource {
created_at?: string | null;
id: string;
title: string;
}

export interface DeveloperConversation {
created_at: string;
finished_at: string | null;
Expand Down Expand Up @@ -3911,6 +3928,9 @@
"DevApiKeyCreate": DevApiKeyCreate;
"DevApiKeyCreated": DevApiKeyCreated;
"DeveloperActionItem": DeveloperActionItem;
"DeveloperAskRequest": DeveloperAskRequest;
"DeveloperAskResponse": DeveloperAskResponse;
"DeveloperAskSource": DeveloperAskSource;
"DeveloperConversation": DeveloperConversation;
"DeveloperConversationActionItem": DeveloperConversationActionItem;
"DeveloperConversationEvent": DeveloperConversationEvent;
Expand Down Expand Up @@ -5570,6 +5590,16 @@
};
};
};
"/v1/dev/user/ask": {
post: {
operationId: "ask";
responses: {
"200": DeveloperAskResponse;
"401": void;
"422": HTTPValidationError;
};
};
};
"/v1/dev/user/conversations": {
get: {
operationId: "listConversations";
Expand Down Expand Up @@ -10739,6 +10769,23 @@
return _res.status === 204 ? (undefined as any) : await _res.json();
}

export async function ask(body: DeveloperAskRequest, init?: OmiApiClientInit): Promise<DeveloperAskResponse> {
const _base = init?.baseURL ?? "";
const _path = `/v1/dev/user/ask`;
const _search = "";
const _res = await fetch(`${_base}${_path}${_search}`, {
method: "POST",
headers: {
...(body ? { 'Content-Type': 'application/json' } : {}),
...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}),
...init?.headers,
},
body: body ? JSON.stringify(body) : undefined,
});
if (!_res.ok) throw new OmiApiError(_res.status, _res);
return _res.status === 204 ? (undefined as any) : await _res.json();
}

export async function listConversations(query: { start_date?: string | null, end_date?: string | null, categories?: string | null, limit?: number, offset?: number, include_transcript?: boolean, folder_id?: string | null, starred?: boolean | null }, init?: OmiApiClientInit): Promise<Array<DeveloperConversation>> {
const _base = init?.baseURL ?? "";
const _path = `/v1/dev/user/conversations`;
Expand Down Expand Up @@ -15688,4 +15735,4 @@
return _res.status === 204 ? (undefined as any) : await _res.json();
}

// Total: 385 client methods generated.
// Total: 386 client methods generated.
Loading
Loading