Skip to content

Commit e1174f3

Browse files
jope-bmclaude
andcommitted
feat: Add v2 prompt endpoints for continue-conversation and search
Implements v2 versions of prompt generation endpoints: - POST /v2/projects/{project_id}/prompt/continue-conversation - POST /v2/projects/{project_id}/prompt/search These endpoints use v2 dependencies (ContextServiceV2Dep, SearchServiceV2Dep, EntityServiceV2Dep, EntityRepositoryV2Dep) for consistent ID-based operations. Includes comprehensive test suite with 7 test cases covering success paths, error handling, and v2 path validation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 8ae3e90 commit e1174f3

5 files changed

Lines changed: 484 additions & 0 deletions

File tree

src/basic_memory/api/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
search_router as v2_search,
2828
resource_router as v2_resource,
2929
directory_router as v2_directory,
30+
prompt_router as v2_prompt,
3031
)
3132
from basic_memory.config import ConfigManager
3233
from basic_memory.services.initialization import initialize_file_sync, initialize_app
@@ -90,6 +91,7 @@ async def lifespan(app: FastAPI): # pragma: no cover
9091
app.include_router(v2_search, prefix="/v2/projects/{project_id}")
9192
app.include_router(v2_resource, prefix="/v2/projects/{project_id}")
9293
app.include_router(v2_directory, prefix="/v2/projects/{project_id}")
94+
app.include_router(v2_prompt, prefix="/v2/projects/{project_id}")
9395
app.include_router(v2_project, prefix="/v2")
9496

9597
# Project resource router works across projects

src/basic_memory/api/v2/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
resource_router,
2020
search_router,
2121
directory_router,
22+
prompt_router,
2223
)
2324

2425
__all__ = [
@@ -28,4 +29,5 @@
2829
"resource_router",
2930
"search_router",
3031
"directory_router",
32+
"prompt_router",
3133
]

src/basic_memory/api/v2/routers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from basic_memory.api.v2.routers.search_router import router as search_router
77
from basic_memory.api.v2.routers.resource_router import router as resource_router
88
from basic_memory.api.v2.routers.directory_router import router as directory_router
9+
from basic_memory.api.v2.routers.prompt_router import router as prompt_router
910

1011
__all__ = [
1112
"knowledge_router",
@@ -14,4 +15,5 @@
1415
"search_router",
1516
"resource_router",
1617
"directory_router",
18+
"prompt_router",
1719
]
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
"""V2 Prompt Router - ID-based prompt generation operations.
2+
3+
This router uses v2 dependencies for consistent project ID handling.
4+
Prompt endpoints are action-based (not resource-based), so they don't
5+
have entity IDs in URLs - they generate formatted prompts from queries.
6+
"""
7+
8+
from datetime import datetime, timezone
9+
from fastapi import APIRouter, HTTPException, status
10+
from loguru import logger
11+
12+
from basic_memory.api.routers.utils import to_graph_context, to_search_results
13+
from basic_memory.api.template_loader import template_loader
14+
from basic_memory.schemas.base import parse_timeframe
15+
from basic_memory.deps import (
16+
ContextServiceV2Dep,
17+
EntityRepositoryV2Dep,
18+
SearchServiceV2Dep,
19+
EntityServiceV2Dep,
20+
ProjectIdPathDep,
21+
)
22+
from basic_memory.schemas.prompt import (
23+
ContinueConversationRequest,
24+
SearchPromptRequest,
25+
PromptResponse,
26+
PromptMetadata,
27+
)
28+
from basic_memory.schemas.search import SearchItemType, SearchQuery
29+
30+
router = APIRouter(prefix="/prompt", tags=["prompt-v2"])
31+
32+
33+
@router.post("/continue-conversation", response_model=PromptResponse)
34+
async def continue_conversation(
35+
project_id: ProjectIdPathDep,
36+
search_service: SearchServiceV2Dep,
37+
entity_service: EntityServiceV2Dep,
38+
context_service: ContextServiceV2Dep,
39+
entity_repository: EntityRepositoryV2Dep,
40+
request: ContinueConversationRequest,
41+
) -> PromptResponse:
42+
"""Generate a prompt for continuing a conversation.
43+
44+
This endpoint takes a topic and/or timeframe and generates a prompt with
45+
relevant context from the knowledge base.
46+
47+
Args:
48+
project_id: Validated numeric project ID from URL path
49+
request: The request parameters
50+
51+
Returns:
52+
Formatted continuation prompt with context
53+
"""
54+
logger.info(
55+
f"V2 Generating continue conversation prompt for project {project_id}, "
56+
f"topic: {request.topic}, timeframe: {request.timeframe}"
57+
)
58+
59+
since = parse_timeframe(request.timeframe) if request.timeframe else None
60+
61+
# Initialize search results
62+
search_results = []
63+
64+
# Get data needed for template
65+
if request.topic:
66+
query = SearchQuery(text=request.topic, after_date=request.timeframe)
67+
results = await search_service.search(query, limit=request.search_items_limit)
68+
search_results = await to_search_results(entity_service, results)
69+
70+
# Build context from results
71+
all_hierarchical_results = []
72+
for result in search_results:
73+
if hasattr(result, "permalink") and result.permalink:
74+
# Get hierarchical context using the new dataclass-based approach
75+
context_result = await context_service.build_context(
76+
result.permalink,
77+
depth=request.depth,
78+
since=since,
79+
max_related=request.related_items_limit,
80+
include_observations=True, # Include observations for entities
81+
)
82+
83+
# Process results into the schema format
84+
graph_context = await to_graph_context(
85+
context_result, entity_repository=entity_repository
86+
)
87+
88+
# Add results to our collection (limit to top results for each permalink)
89+
if graph_context.results:
90+
all_hierarchical_results.extend(graph_context.results[:3])
91+
92+
# Limit to a reasonable number of total results
93+
all_hierarchical_results = all_hierarchical_results[:10]
94+
95+
template_context = {
96+
"topic": request.topic,
97+
"timeframe": request.timeframe,
98+
"hierarchical_results": all_hierarchical_results,
99+
"has_results": len(all_hierarchical_results) > 0,
100+
}
101+
else:
102+
# If no topic, get recent activity
103+
context_result = await context_service.build_context(
104+
types=[SearchItemType.ENTITY],
105+
depth=request.depth,
106+
since=since,
107+
max_related=request.related_items_limit,
108+
include_observations=True,
109+
)
110+
recent_context = await to_graph_context(context_result, entity_repository=entity_repository)
111+
112+
hierarchical_results = recent_context.results[:5] # Limit to top 5 recent items
113+
114+
template_context = {
115+
"topic": f"Recent Activity from ({request.timeframe})",
116+
"timeframe": request.timeframe,
117+
"hierarchical_results": hierarchical_results,
118+
"has_results": len(hierarchical_results) > 0,
119+
}
120+
121+
try:
122+
# Render template
123+
rendered_prompt = await template_loader.render(
124+
"prompts/continue_conversation.hbs", template_context
125+
)
126+
127+
# Calculate metadata
128+
# Count items of different types
129+
observation_count = 0
130+
relation_count = 0
131+
entity_count = 0
132+
133+
# Get the hierarchical results from the template context
134+
hierarchical_results_for_count = template_context.get("hierarchical_results", [])
135+
136+
# For topic-based search
137+
if request.topic:
138+
for item in hierarchical_results_for_count:
139+
if hasattr(item, "observations"):
140+
observation_count += len(item.observations) if item.observations else 0
141+
142+
if hasattr(item, "related_results"):
143+
for related in item.related_results or []:
144+
if hasattr(related, "type"):
145+
if related.type == "relation":
146+
relation_count += 1
147+
elif related.type == "entity": # pragma: no cover
148+
entity_count += 1 # pragma: no cover
149+
# For recent activity
150+
else:
151+
for item in hierarchical_results_for_count:
152+
if hasattr(item, "observations"):
153+
observation_count += len(item.observations) if item.observations else 0
154+
155+
if hasattr(item, "related_results"):
156+
for related in item.related_results or []:
157+
if hasattr(related, "type"):
158+
if related.type == "relation":
159+
relation_count += 1
160+
elif related.type == "entity": # pragma: no cover
161+
entity_count += 1 # pragma: no cover
162+
163+
# Build metadata
164+
metadata = {
165+
"query": request.topic,
166+
"timeframe": request.timeframe,
167+
"search_count": len(search_results)
168+
if request.topic
169+
else 0, # Original search results count
170+
"context_count": len(hierarchical_results_for_count),
171+
"observation_count": observation_count,
172+
"relation_count": relation_count,
173+
"total_items": (
174+
len(hierarchical_results_for_count)
175+
+ observation_count
176+
+ relation_count
177+
+ entity_count
178+
),
179+
"search_limit": request.search_items_limit,
180+
"context_depth": request.depth,
181+
"related_limit": request.related_items_limit,
182+
"generated_at": datetime.now(timezone.utc).isoformat(),
183+
}
184+
185+
prompt_metadata = PromptMetadata(**metadata)
186+
187+
return PromptResponse(
188+
prompt=rendered_prompt, context=template_context, metadata=prompt_metadata
189+
)
190+
except Exception as e:
191+
logger.error(f"Error rendering continue conversation template: {e}")
192+
raise HTTPException(
193+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
194+
detail=f"Error rendering prompt template: {str(e)}",
195+
)
196+
197+
198+
@router.post("/search", response_model=PromptResponse)
199+
async def search_prompt(
200+
project_id: ProjectIdPathDep,
201+
search_service: SearchServiceV2Dep,
202+
entity_service: EntityServiceV2Dep,
203+
request: SearchPromptRequest,
204+
page: int = 1,
205+
page_size: int = 10,
206+
) -> PromptResponse:
207+
"""Generate a prompt for search results.
208+
209+
This endpoint takes a search query and formats the results into a helpful
210+
prompt with context and suggestions.
211+
212+
Args:
213+
project_id: Validated numeric project ID from URL path
214+
request: The search parameters
215+
page: The page number for pagination
216+
page_size: The number of results per page, defaults to 10
217+
218+
Returns:
219+
Formatted search results prompt with context
220+
"""
221+
logger.info(
222+
f"V2 Generating search prompt for project {project_id}, "
223+
f"query: {request.query}, timeframe: {request.timeframe}"
224+
)
225+
226+
limit = page_size
227+
offset = (page - 1) * page_size
228+
229+
query = SearchQuery(text=request.query, after_date=request.timeframe)
230+
results = await search_service.search(query, limit=limit, offset=offset)
231+
search_results = await to_search_results(entity_service, results)
232+
233+
template_context = {
234+
"query": request.query,
235+
"timeframe": request.timeframe,
236+
"results": search_results,
237+
"has_results": len(search_results) > 0,
238+
"result_count": len(search_results),
239+
}
240+
241+
try:
242+
# Render template
243+
rendered_prompt = await template_loader.render("prompts/search.hbs", template_context)
244+
245+
# Build metadata
246+
metadata = {
247+
"query": request.query,
248+
"timeframe": request.timeframe,
249+
"search_count": len(search_results),
250+
"context_count": len(search_results),
251+
"observation_count": 0, # Search results don't include observations
252+
"relation_count": 0, # Search results don't include relations
253+
"total_items": len(search_results),
254+
"search_limit": limit,
255+
"context_depth": 0, # No context depth for basic search
256+
"related_limit": 0, # No related items for basic search
257+
"generated_at": datetime.now(timezone.utc).isoformat(),
258+
}
259+
260+
prompt_metadata = PromptMetadata(**metadata)
261+
262+
return PromptResponse(
263+
prompt=rendered_prompt, context=template_context, metadata=prompt_metadata
264+
)
265+
except Exception as e:
266+
logger.error(f"Error rendering search template: {e}")
267+
raise HTTPException(
268+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
269+
detail=f"Error rendering prompt template: {str(e)}",
270+
)

0 commit comments

Comments
 (0)