feat: expand tool catalog and improve 'what' search recall
- Add 32 new tool and dockerfile entries to README.md catalog. - Increase 'what' shortlist limit to 100 for better search recall. - Update 'what' default model to gemma4 and improve robust JSON parsing.
This commit is contained in:
@@ -21,7 +21,7 @@ from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.resolve()
|
||||
README_PATH = REPO_ROOT / "README.md"
|
||||
DEFAULT_MODEL = os.environ.get("WHAT_OLLAMA_MODEL", "ministral-3:3b")
|
||||
DEFAULT_MODEL = os.environ.get("WHAT_OLLAMA_MODEL", "gemma4")
|
||||
CATALOG_HEADING = "## Tool Catalog"
|
||||
ENTRY_RE = re.compile(
|
||||
r"^- `([^`]+)` \| goal: (.*?) \| usage: (.*)$"
|
||||
@@ -123,13 +123,20 @@ def build_prompt(query: str, entries: list[dict[str, str]]) -> str:
|
||||
return f"""You are selecting tools from a repository catalog.
|
||||
Use only the catalog below. Prefer direct matches. Use archived tools only if they clearly fit the request.
|
||||
|
||||
Return strict JSON only. The response must be a JSON array with up to 8 objects.
|
||||
Each object must contain:
|
||||
- "path": exact catalog path
|
||||
- "reason": one short sentence
|
||||
Return strict JSON matching this schema exactly:
|
||||
{{
|
||||
"results": [
|
||||
{{
|
||||
"path": "exact catalog path",
|
||||
"reason": "one short sentence explaining why this tool matches"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
Do not invent paths. Do not include markdown.
|
||||
Prefer the entry whose action best matches the query: compare beats hash for comparison queries, open beats convert for opening queries, and mount beats inspect for mount queries.
|
||||
Constraints:
|
||||
- The "results" array must contain up to 8 objects.
|
||||
- Do not invent paths.
|
||||
- Prefer the entry whose action best matches the query: compare beats hash for comparison queries, open beats convert for opening queries, and mount beats inspect for mount queries.
|
||||
|
||||
Query: {query}
|
||||
|
||||
@@ -142,7 +149,7 @@ def tokenize(text: str) -> set[str]:
|
||||
return set(TOKEN_RE.findall(text.lower()))
|
||||
|
||||
|
||||
def shortlist_entries(query: str, entries: list[dict[str, str]], limit: int = 28) -> list[dict[str, str]]:
|
||||
def shortlist_entries(query: str, entries: list[dict[str, str]], limit: int = 100) -> list[dict[str, str]]:
|
||||
query_tokens = tokenize(query)
|
||||
if not query_tokens:
|
||||
return entries[:limit]
|
||||
@@ -163,28 +170,45 @@ def shortlist_entries(query: str, entries: list[dict[str, str]], limit: int = 28
|
||||
|
||||
|
||||
def extract_json_array(output: str) -> list[dict[str, str]]:
|
||||
match = re.search(r"\[\s*\{.*\}\s*\]", output, re.DOTALL)
|
||||
# Step 1: Clean and find the root object boundary if Ollama prefixes anything
|
||||
match = re.search(r"\{\s*.*\}\s*", output, re.DOTALL)
|
||||
payload = match.group(0) if match else output
|
||||
|
||||
data = json.loads(payload)
|
||||
if not isinstance(data, list):
|
||||
raise WhatError("Model output must be a JSON array.")
|
||||
try:
|
||||
# ALLOW literal newlines/control characters inside string properties
|
||||
data = json.loads(payload, strict=False)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise WhatError(f"Failed to parse model output as JSON: {exc}")
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise WhatError("Model output must be a root JSON object.")
|
||||
|
||||
# Step 2: Safe navigation into the expected schema array
|
||||
results_list = data.get("results")
|
||||
if results_list is None:
|
||||
raise WhatError("Missing 'results' key in model JSON response.")
|
||||
|
||||
if not isinstance(results_list, list):
|
||||
raise WhatError("The 'results' property must be a JSON array.")
|
||||
|
||||
# Step 3: Extract and normalize items
|
||||
normalized: list[dict[str, str]] = []
|
||||
for item in data:
|
||||
for item in results_list:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
path = str(item.get("path", "")).strip()
|
||||
reason = str(item.get("reason", "")).strip()
|
||||
# Clean up any literal newlines the model injected into the text
|
||||
reason = str(item.get("reason", "")).replace("\n", " ").strip()
|
||||
if path:
|
||||
normalized.append({"path": path, "reason": reason})
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def run_ollama_once(prompt: str, model: str) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ollama", "run", model, prompt],
|
||||
["ollama", "run", "--format", "json", "--hidethinking", model, prompt],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
@@ -206,8 +230,9 @@ def run_ollama(prompt: str, model: str) -> list[dict[str, str]]:
|
||||
return extract_json_array(first_output)
|
||||
except (json.JSONDecodeError, WhatError):
|
||||
repair_prompt = (
|
||||
"Rewrite the following response as strict JSON only.\n"
|
||||
'Target format: [{"path":"exact catalog path","reason":"short reason"}]\n'
|
||||
"Rewrite the following response as strict JSON matching the target schema.\n"
|
||||
"Target format:\n"
|
||||
'{\n "results": [{"path":"exact catalog path","reason":"short reason"}]\n}\n'
|
||||
"Do not add markdown or commentary.\n\n"
|
||||
f"Response to repair:\n{first_output}\n"
|
||||
)
|
||||
@@ -300,4 +325,4 @@ def main() -> int:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user