Skip to content

Commit c1bdbb4

Browse files
committed
feat(search): add type inference fallback and cached search index
Add automatic type inference when a type filter returns no matches and fallback to inferred types or no-type search to surface relevant results. Build and cache a derived search index to speed suggestions and improve search quality. Tighten type matching to require exact type equality. - Add SearchIndex and cache path to CacheManager - Implement DocService.getSearchIndex(), searchWithFallback(), suggestTypes(), getAvailableTypes() - Update formatSearchResults to display fallback info and suggestions - Enhance python_docs tool; add suggest_python_doc_types - Bump package version to 0.2.0 - Update tests and mocks; add tests for search index inference
1 parent e1d9f1e commit c1bdbb4

File tree

8 files changed

+757
-14
lines changed

8 files changed

+757
-14
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "opencode-python-docs",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"description": "OpenCode plugin for Python documentation lookup via DevDocs",
55
"module": "dist/index.js",
66
"main": "dist/index.js",

src/cache.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { dirname, join } from "node:path";
1313
/** Interface for cache operations, enabling dependency injection in tests. */
1414
export interface CacheManagerInterface {
1515
getIndexPath(version: string): string;
16+
getSearchIndexPath(version: string): string;
1617
getDocPath(version: string, docPath: string): string;
1718
isValid(path: string, ttlMs: number): boolean;
1819
read<T>(path: string): T | null;
@@ -43,6 +44,10 @@ export function CacheManager(cacheRoot: string): CacheManagerInterface {
4344
return join(cacheRoot, `python-${version}.json`);
4445
},
4546

47+
getSearchIndexPath(version: string): string {
48+
return join(cacheRoot, `python-${version}-search-index.json`);
49+
},
50+
4651
getDocPath(version: string, docPath: string): string {
4752
return join(cacheRoot, "docs", version, `${hash(docPath)}.json`);
4853
},

src/doc-service.ts

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ import type { PythonVersion } from "./config";
33
import { CONFIG } from "./config";
44
import { htmlToMarkdown } from "./html-to-markdown";
55
import type { Logger } from "./logger";
6+
import {
7+
createSearchIndex,
8+
getAvailableTypes,
9+
inferTypesForQuery,
10+
type SearchIndex,
11+
type TypeInferenceResult,
12+
} from "./search-index";
613
import type { CachedDoc, DocIndex, FetchedDoc } from "./types";
714

815
async function fetchWithTimeout(url: string, log: Logger): Promise<string> {
@@ -25,8 +32,22 @@ async function fetchWithTimeout(url: string, log: Logger): Promise<string> {
2532
/** Service for fetching and searching Python documentation. */
2633
export interface DocService {
2734
getIndex(version: PythonVersion): Promise<DocIndex>;
35+
getSearchIndex(version: PythonVersion): Promise<SearchIndex>;
2836
getDoc(version: PythonVersion, path: string): Promise<FetchedDoc>;
2937
search(index: DocIndex, query: string, type?: string, limit?: number): DocIndex["entries"];
38+
searchWithFallback(
39+
index: DocIndex,
40+
searchIndex: SearchIndex,
41+
query: string,
42+
type?: string,
43+
limit?: number,
44+
): Promise<{
45+
results: DocIndex["entries"];
46+
fallbackUsed: boolean;
47+
typeInference?: TypeInferenceResult;
48+
}>;
49+
suggestTypes(searchIndex: SearchIndex, query: string): TypeInferenceResult;
50+
getAvailableTypes(searchIndex: SearchIndex): string[];
3051
}
3152

3253
/**
@@ -51,6 +72,32 @@ export function createDocService(cache: CacheManagerInterface, log: Logger): Doc
5172
return index;
5273
},
5374

75+
async getSearchIndex(version: PythonVersion): Promise<SearchIndex> {
76+
const searchIndexPath = cache.getSearchIndexPath(version);
77+
78+
// Check if we have a valid cached search index
79+
if (cache.isValid(searchIndexPath, CONFIG.indexTtlMs)) {
80+
const cached = cache.read<SearchIndex>(searchIndexPath);
81+
if (cached) {
82+
await log.info(`Using cached search index for Python ${version}`);
83+
return cached;
84+
}
85+
}
86+
87+
// Build search index from the main index
88+
await log.info(`Building search index for Python ${version}...`);
89+
const index = await this.getIndex(version);
90+
const searchIndex = createSearchIndex(index, version);
91+
92+
// Cache the search index
93+
cache.write(searchIndexPath, searchIndex);
94+
await log.info(
95+
`Cached search index with ${searchIndex.keywordMappings.length} keyword mappings`,
96+
);
97+
98+
return searchIndex;
99+
},
100+
54101
async getDoc(version: PythonVersion, path: string): Promise<FetchedDoc> {
55102
const normalizedPath = path.endsWith(".html") ? path.slice(0, -5) : path;
56103
const docPath = cache.getDocPath(version, normalizedPath);
@@ -89,7 +136,7 @@ export function createDocService(cache: CacheManagerInterface, log: Logger): Doc
89136
if (results.length >= maxResults) break;
90137

91138
const nameMatch = entry.name.toLowerCase().includes(q);
92-
const typeMatch = !t || entry.type.toLowerCase().includes(t);
139+
const typeMatch = !t || entry.type.toLowerCase() === t;
93140

94141
if (nameMatch && typeMatch) {
95142
results.push(entry);
@@ -98,5 +145,71 @@ export function createDocService(cache: CacheManagerInterface, log: Logger): Doc
98145

99146
return results;
100147
},
148+
149+
async searchWithFallback(
150+
index: DocIndex,
151+
searchIndex: SearchIndex,
152+
query: string,
153+
type?: string,
154+
limit?: number,
155+
): Promise<{
156+
results: DocIndex["entries"];
157+
fallbackUsed: boolean;
158+
typeInference?: TypeInferenceResult;
159+
}> {
160+
// Try the requested search first
161+
const results = this.search(index, query, type, limit);
162+
163+
// If we have results or no type filter, return as-is
164+
if (results.length > 0 || !type) {
165+
return { results, fallbackUsed: false };
166+
}
167+
168+
// No results with type filter - use type inference to suggest alternatives
169+
await log.info(`No results for "${query}" with type "${type}". Running type inference...`);
170+
171+
const typeInference = inferTypesForQuery(query, searchIndex);
172+
173+
// Try searching without the type filter
174+
const resultsNoFilter = this.search(index, query, undefined, limit);
175+
176+
// Try searching with the most likely inferred types
177+
let resultsWithInferred: DocIndex["entries"] = [];
178+
for (const inferredType of typeInference.inferredTypes.slice(0, 2)) {
179+
const inferredResults = this.search(
180+
index,
181+
query,
182+
inferredType,
183+
Math.ceil((limit ?? CONFIG.defaultLimit) / 2),
184+
);
185+
resultsWithInferred = resultsWithInferred.concat(inferredResults);
186+
}
187+
188+
// Combine results: prioritize inferred type matches, then no-filter matches
189+
const seen = new Set<string>();
190+
const combinedResults: DocIndex["entries"] = [];
191+
192+
for (const entry of resultsWithInferred.concat(resultsNoFilter)) {
193+
if (!seen.has(entry.path)) {
194+
seen.add(entry.path);
195+
combinedResults.push(entry);
196+
if (combinedResults.length >= (limit ?? CONFIG.defaultLimit)) break;
197+
}
198+
}
199+
200+
return {
201+
results: combinedResults,
202+
fallbackUsed: true,
203+
typeInference,
204+
};
205+
},
206+
207+
suggestTypes(searchIndex: SearchIndex, query: string): TypeInferenceResult {
208+
return inferTypesForQuery(query, searchIndex);
209+
},
210+
211+
getAvailableTypes(searchIndex: SearchIndex): string[] {
212+
return getAvailableTypes(searchIndex);
213+
},
101214
};
102215
}

src/formatters.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,51 @@
1+
import type { TypeInferenceResult } from "./search-index";
12
import type { AnchorIndex, DocEntry } from "./types";
23

34
/**
45
* Formats search results into a human-readable string.
56
* @param results - Matched documentation entries.
67
* @param query - The original search query.
78
* @param version - Python version searched.
9+
* @param fallbackUsed - Whether type inference fallback was used.
10+
* @param typeInference - Type inference result when fallback was used.
811
* @returns Formatted string listing results or a no-results message.
912
*/
10-
export function formatSearchResults(results: DocEntry[], query: string, version: string): string {
13+
export function formatSearchResults(
14+
results: DocEntry[],
15+
query: string,
16+
version: string,
17+
fallbackUsed?: boolean,
18+
typeInference?: TypeInferenceResult,
19+
): string {
1120
if (!results.length) {
12-
return `No results found for "${query}" in Python ${version} docs.`;
21+
let message = `No results found for "${query}" in Python ${version} docs.`;
22+
if (typeInference && typeInference.inferredTypes.length > 0) {
23+
message += `\n\nSuggested types based on your query:\n`;
24+
message += typeInference.inferredTypes.map((t) => ` - ${t}`).join("\n");
25+
if (typeInference.alternativeTypes.length > 0) {
26+
message += `\n\nAlternative types:\n`;
27+
message += typeInference.alternativeTypes.map((t) => ` - ${t}`).join("\n");
28+
}
29+
}
30+
return message;
1331
}
1432

15-
return [
33+
const lines: string[] = [
1634
`Found ${results.length} result(s) for "${query}" in Python ${version} docs.`,
17-
"",
18-
...results.map((r) => `- ${r.name} [${r.type}] -> ${r.path}`),
19-
"",
20-
"Use fetch_python_doc with the path to get the full documentation.",
21-
].join("\n");
35+
];
36+
37+
if (fallbackUsed && typeInference) {
38+
lines.push("");
39+
lines.push("⚠️ Original search returned no results. Used type inference to find matches.");
40+
lines.push(` Inferred types: ${typeInference.inferredTypes.join(", ")}`);
41+
}
42+
43+
lines.push("");
44+
lines.push(...results.map((r) => `- ${r.name} [${r.type}] -> ${r.path}`));
45+
lines.push("");
46+
lines.push("Use fetch_python_doc with the path to get the full documentation.");
47+
48+
return lines.join("\n");
2249
}
2350

2451
/**

0 commit comments

Comments
 (0)