|
7 | 7 |
|
8 | 8 | import json |
9 | 9 | from typing import Any, Dict, List, Optional, cast |
10 | | -from uuid import UUID |
11 | 10 |
|
12 | 11 | from fastmcp import Context |
13 | 12 | from loguru import logger |
14 | 13 |
|
15 | 14 | from basic_memory.mcp.server import mcp |
16 | | -from basic_memory.mcp.tools.project_management import list_memory_projects |
17 | 15 | from basic_memory.mcp.tools.read_note import read_note |
18 | 16 | from basic_memory.mcp.tools.search import search_notes |
19 | 17 | from basic_memory.schemas.search import SearchResponse, SearchResult |
20 | 18 |
|
21 | 19 |
|
22 | | -def _valid_project_id(value: object) -> str | None: |
23 | | - """Return a UUID project id string when one is present.""" |
24 | | - if not isinstance(value, str) or not value.strip(): |
25 | | - return None |
26 | | - try: |
27 | | - return str(UUID(value)) |
28 | | - except ValueError: |
29 | | - return None |
30 | | - |
31 | | - |
32 | | -def _matches_constrained_project(project: dict[str, Any], constrained_project: object) -> bool: |
33 | | - """Return True when a project list row satisfies BASIC_MEMORY_MCP_PROJECT.""" |
34 | | - if not isinstance(constrained_project, str) or not constrained_project.strip(): |
35 | | - return True |
36 | | - |
37 | | - candidates = { |
38 | | - value |
39 | | - for value in ( |
40 | | - project.get("name"), |
41 | | - project.get("qualified_name"), |
42 | | - project.get("external_id"), |
43 | | - ) |
44 | | - if isinstance(value, str) |
45 | | - } |
46 | | - return constrained_project in candidates |
47 | | - |
48 | | - |
49 | | -def _search_project_refs(projects_payload: object) -> list[dict[str, str | None]]: |
50 | | - """Extract project routing refs for account-scoped ChatGPT search.""" |
51 | | - if not isinstance(projects_payload, dict): |
52 | | - return [] |
53 | | - |
54 | | - payload = cast(dict[str, Any], projects_payload) |
55 | | - projects = payload.get("projects") |
56 | | - if not isinstance(projects, list): |
57 | | - return [] |
58 | | - |
59 | | - refs: list[dict[str, str | None]] = [] |
60 | | - seen: set[tuple[str | None, str | None]] = set() |
61 | | - constrained_project = payload.get("constrained_project") |
62 | | - for item in projects: |
63 | | - if not isinstance(item, dict) or not _matches_constrained_project( |
64 | | - item, constrained_project |
65 | | - ): |
66 | | - continue |
67 | | - |
68 | | - project = item.get("qualified_name") or item.get("name") |
69 | | - project_name = project if isinstance(project, str) and project.strip() else None |
70 | | - project_id = _valid_project_id(item.get("external_id")) |
71 | | - if project_name is None and project_id is None: |
72 | | - continue |
73 | | - |
74 | | - key = (project_name, project_id) |
75 | | - if key in seen: |
76 | | - continue |
77 | | - seen.add(key) |
78 | | - refs.append({"project": project_name, "project_id": project_id}) |
79 | | - return refs |
80 | | - |
81 | | - |
82 | | -def _raw_results_from_search_payload( |
83 | | - results: SearchResponse | list[SearchResult | dict[str, Any]] | dict[str, Any], |
84 | | -) -> list[SearchResult | dict[str, Any]]: |
85 | | - """Return the result list from any search_notes JSON-compatible payload.""" |
86 | | - if isinstance(results, SearchResponse): |
87 | | - return list(results.results) |
88 | | - if isinstance(results, dict): |
89 | | - nested_results = results.get("results") |
90 | | - return ( |
91 | | - cast(list[SearchResult | dict[str, Any]], nested_results) |
92 | | - if isinstance(nested_results, list) |
93 | | - else [] |
94 | | - ) |
95 | | - return list(results) |
96 | | - |
97 | | - |
98 | | -def _qualify_permalink_for_project(permalink: object, project: str | None) -> object: |
99 | | - """Return a workspace-qualified permalink when the project ref supplies one.""" |
100 | | - if not isinstance(permalink, str) or not permalink.strip(): |
101 | | - return permalink |
102 | | - if not isinstance(project, str) or "/" not in project.strip("/"): |
103 | | - return permalink |
104 | | - |
105 | | - normalized_permalink = permalink.strip("/") |
106 | | - qualified_project = project.strip("/") |
107 | | - if normalized_permalink == qualified_project or normalized_permalink.startswith( |
108 | | - f"{qualified_project}/" |
109 | | - ): |
110 | | - return normalized_permalink |
111 | | - |
112 | | - workspace_slug, project_permalink = qualified_project.split("/", 1) |
113 | | - if normalized_permalink == project_permalink or normalized_permalink.startswith( |
114 | | - f"{project_permalink}/" |
115 | | - ): |
116 | | - return f"{workspace_slug}/{normalized_permalink}" |
117 | | - return f"{qualified_project}/{normalized_permalink}" |
118 | | - |
119 | | - |
120 | | -def _qualify_results_for_project( |
121 | | - results: list[SearchResult | dict[str, Any]], |
122 | | - project_ref: dict[str, str | None], |
123 | | -) -> list[dict[str, Any]]: |
124 | | - """Attach the searched workspace/project prefix to each ChatGPT result id.""" |
125 | | - qualified: list[dict[str, Any]] = [] |
126 | | - for result in results: |
127 | | - if isinstance(result, SearchResult): |
128 | | - result_data = result.model_dump() |
129 | | - else: |
130 | | - result_data = dict(result) |
131 | | - result_data["permalink"] = _qualify_permalink_for_project( |
132 | | - result_data.get("permalink"), |
133 | | - project_ref.get("project"), |
134 | | - ) |
135 | | - qualified.append(result_data) |
136 | | - return qualified |
137 | | - |
138 | | - |
139 | | -def _result_score(result: SearchResult | dict[str, Any]) -> float: |
140 | | - """Return a comparable search score for merged project results.""" |
141 | | - if isinstance(result, SearchResult): |
142 | | - return result.score |
143 | | - score = result.get("score") |
144 | | - return float(score) if isinstance(score, int | float) else 0.0 |
145 | | - |
146 | | - |
147 | 20 | def _identifier_for_read_note(identifier: str) -> str: |
148 | 21 | """Convert ChatGPT result ids into routable Basic Memory identifiers.""" |
149 | 22 | stripped = identifier.strip() |
@@ -252,69 +125,25 @@ async def search( |
252 | 125 | logger.info(f"ChatGPT search request: query='{query}'") |
253 | 126 |
|
254 | 127 | try: |
255 | | - project_refs = _search_project_refs( |
256 | | - await list_memory_projects(output_format="json", context=context) |
| 128 | + # Keep this adapter tiny: the real search behavior lives in search_notes. |
| 129 | + results = await search_notes( |
| 130 | + query=query, |
| 131 | + page=1, |
| 132 | + page_size=10, |
| 133 | + output_format="json", |
| 134 | + context=context, |
257 | 135 | ) |
258 | 136 |
|
259 | | - raw_results: list[SearchResult | dict[str, Any]] = [] |
260 | | - if project_refs: |
261 | | - # Trigger: ChatGPT only has a strict search(query) -> fetch(id) contract. |
262 | | - # Why: default-project search strands notes in other workspaces/projects. |
263 | | - # Outcome: query every discovered project, then merge the top matches. |
264 | | - for project_ref in project_refs: |
265 | | - results = await search_notes( |
266 | | - query=query, |
267 | | - project=project_ref["project"], |
268 | | - project_id=project_ref["project_id"], |
269 | | - page=1, |
270 | | - page_size=10, |
271 | | - output_format="json", |
272 | | - context=context, |
273 | | - ) |
274 | | - |
275 | | - if isinstance(results, str): |
276 | | - logger.warning(f"Search failed with error: {results[:100]}...") |
277 | | - search_results = { |
278 | | - "results": [], |
279 | | - "error": "Search failed", |
280 | | - "error_details": results[:500], |
281 | | - } |
282 | | - return [ |
283 | | - { |
284 | | - "type": "text", |
285 | | - "text": json.dumps(search_results, ensure_ascii=False), |
286 | | - } |
287 | | - ] |
288 | | - |
289 | | - raw_results.extend( |
290 | | - _qualify_results_for_project( |
291 | | - _raw_results_from_search_payload(results), |
292 | | - project_ref, |
293 | | - ) |
294 | | - ) |
295 | | - raw_results = sorted(raw_results, key=_result_score, reverse=True)[:10] |
296 | | - else: |
297 | | - # Trigger: project discovery returned no structured rows. |
298 | | - # Why: preserve the legacy single-project behavior when discovery is unavailable. |
299 | | - # Outcome: let search_notes resolve the default project as before. |
300 | | - results = await search_notes( |
301 | | - query=query, |
302 | | - page=1, |
303 | | - page_size=10, |
304 | | - output_format="json", |
305 | | - context=context, |
306 | | - ) |
307 | | - |
308 | | - if isinstance(results, str): |
309 | | - logger.warning(f"Search failed with error: {results[:100]}...") |
310 | | - search_results = { |
311 | | - "results": [], |
312 | | - "error": "Search failed", |
313 | | - "error_details": results[:500], # Truncate long error messages |
314 | | - } |
315 | | - return [{"type": "text", "text": json.dumps(search_results, ensure_ascii=False)}] |
| 137 | + if isinstance(results, str): |
| 138 | + logger.warning(f"Search failed with error: {results[:100]}...") |
| 139 | + search_results = { |
| 140 | + "results": [], |
| 141 | + "error": "Search failed", |
| 142 | + "error_details": results[:500], # Truncate long error messages |
| 143 | + } |
| 144 | + return [{"type": "text", "text": json.dumps(search_results, ensure_ascii=False)}] |
316 | 145 |
|
317 | | - raw_results = _raw_results_from_search_payload(results) |
| 146 | + raw_results = results.get("results", []) if isinstance(results, dict) else [] |
318 | 147 |
|
319 | 148 | formatted_results = _format_search_results_for_chatgpt(raw_results) |
320 | 149 | search_results = { |
|
0 commit comments