Skip to content

Commit b7ab930

Browse files
bearlikemewbo-ai[bot]
authored andcommitted
✨ feat(api,console): coupled tier/model composer — the budget pill names its model preset, the model pill names the tier
## New features **`GET /api/agentic_search/tiers` — tier→model presets on the wire.** Returns `{default_tier, tiers: {fast,auto,deep: <model>}}` with each tier resolved exactly like the drive (`scg.traversal.tier_models` → `llm.default_model` fallback), so the console can show which model a tier actually runs *before* submit. Pure config read — available regardless of `scg.enabled`. **The composer pills now describe the real workflow (console).** The previous two disconnected dropdowns (Fast/Auto/Deep vs "Default model") read as two entry points for the same datum. They are now one coupled preset + a deviation: - The **budget dropdown** rows are two-line under a "Search budget — depth · fan-out · model" label: tier name + a depth/fan-out hint ("shallow · few probes" / "balanced (default)" / "max depth · wide fan-out") over the tier's model preset (brand icon + mono name). Picking a tier visibly picks the whole preset — budget AND brain. - The **model pill's** default label names the current tier's preset ("Auto · claude-sonnet-4-6") instead of a vague "Default model", so the resolved model is always visible at rest; an explicit pick replaces it. Hover title: "Model for this run — overrides the tier's preset". - **Picking a tier clears the model override** (`AgenticSearchView.handleTierChange`): the tier auto-selects its model, and the user deviates from there — a stale override from a previous tier can no longer silently win over a fresh tier pick. `ModelPicker` gains an additive `title` prop (native hover hint — the repo's `title` idiom); wiki call sites unchanged. ## Stability - Route tests: `GET /tiers` shape + per-tier resolved model, blank-mapping fallback to `llm.default_model`, 401 without a key (`tests/agentic_search/scg/test_routes_scg.py`). - New `SearchBar.test.tsx` locks the coupled contract: default label names the tier preset and follows the tier prop, an override replaces it, budget rows carry hints + per-tier models, row click emits the tier id (Radix menu opened via keyboard — the reliable jsdom path). - `searchBarAutocomplete.test.tsx` now mounts under a `QueryClientProvider` (SearchBar's `useTiers` query requires one — same migration the model pill forced on the panel tests in #94). ## Docs - Console + API `agentic_search/CLAUDE.md` and `apps/mewbo_api/CLAUDE.md` refreshed: the "one coupled preset, two pills" contract, the tiers endpoint, and the reset-on-tier-change rationale. ## Validation - `ruff check` + `mypy` clean on touched Python; `pytest tests/agentic_search` → 392 passed. - Console: `tsc --noEmit` clean, eslint clean on touched files, `vitest run` → 328 passed (40 files). - Caveat: validated locally + via unit/contract tests; not yet exercised against the deployed stack. Co-authored-by: mewbo-ai[bot] <268600793+mewbo-ai[bot]@users.noreply.github.com>
1 parent 57cf883 commit b7ab930

13 files changed

Lines changed: 384 additions & 60 deletions

File tree

apps/mewbo_api/CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ Scope: this file applies to the `apps/mewbo_api/` package. It captures runtime b
5252
- **Optional `model` override (additive, both realtime-family endpoints).** `/v1/structured` (both modes) and `/v1/draft/stream` each accept an optional `model` body field (a LiteLLM name like `openai/gpt-5.4-nano`; non-string → ignored → configured default) so an external caller controls the model per request. Threading: synthesis mode → `SynthesisRunner``StructuredSynthesizer(model_name=...)`; draft → `DraftStreamer(model_name=...)`; agentic mode → applied at the ONE route seam in `StructuredResource._build_responder` (default path passes `model_name=` into `StructuredResponder(...)`, graph-first path takes it via `dataclasses.replace` after `_graph_first_responder` returns — never edit `agentic_search/**`). `StructuredResponder.model_name` reaches the LLM via `_drive → runtime.run_sync(model_name=…) → Orchestrator._model_name → build_chat_model` (it was already wired, not dead). API-level only — no MCP knob, no config setting.
5353
- Agentic Search endpoints (`init_agentic_search`; run store is separate from session transcripts):
5454
- `GET /api/agentic_search/sources?project=` list the source catalog (live-first: configured servers whose discovery failed stay listed `available=false`, not omitted)
55+
- `GET /api/agentic_search/tiers` search-budget tiers + the resolved model preset each runs on (`tier_models``llm.default_model` fallback, mirroring the drive); feeds the console's coupled tier/model composer pills
5556
- `GET/POST /api/agentic_search/workspaces`, `PATCH/DELETE /api/agentic_search/workspaces/<id>` workspace CRUD
5657
- `GET /api/agentic_search/workspaces/<id>/runs` recent run records for a workspace
5758
- `POST /api/agentic_search/runs` create + drive a run (optional `tier` budget knob + per-run `model` override — see agentic_search/CLAUDE.md); returns `{run: RunPayload}` + `run_id`/`session_id`/`status` — echo runner settles synchronously (`completed`), orchestrated returns `running` promptly and settles via a RunRegistry worker (terminal state arrives on the SSE/snapshot surfaces)

apps/mewbo_api/src/mewbo_api/agentic_search/CLAUDE.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,10 @@ never the runner instance. `RunRecord.model` rides the same way: the optional
260260
drive (`run.model or ScgConfig.model_for_tier(run.tier)`) and is echoed on
261261
every `RunPayload` so the deep-link snapshot stays self-sufficient. Per-tier
262262
defaults are config (`scg.traversal.tier_models`); the override is per-run
263-
only — no config write, no restart. The durable decisions + the two silent correctness
263+
only — no config write, no restart. `GET /tiers` exposes the resolved per-tier
264+
preset (tier map → `llm.default_model`, exactly the drive's fallback; pure
265+
config read, NOT gated on `scg.enabled`) so the console's composer can show
266+
which model a tier runs before submit. The durable decisions + the two silent correctness
264267
traps live in **`scg/CLAUDE.md`**; the full spec + research grounding is
265268
**Gitea #19**.
266269

apps/mewbo_api/src/mewbo_api/agentic_search/routes.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
Workspaces + sources (persistent — JSON/Mongo via the store):
66
77
- ``GET /sources?project=`` list the MCP source catalog
8+
- ``GET /tiers`` search-budget tiers + the model preset each runs on
89
- ``GET /workspaces`` list workspaces
910
- ``POST /workspaces`` create workspace
1011
- ``PATCH /workspaces/<id>`` update workspace
@@ -39,6 +40,7 @@
3940
from flask import Response, request, stream_with_context
4041
from flask_restx import Namespace, Resource, fields
4142
from mewbo_core.common import get_logger
43+
from mewbo_core.config import get_config_value
4244
from pydantic import ValidationError
4345

4446
from mewbo_api.request_context import request_surface
@@ -286,6 +288,36 @@ def get(self) -> tuple[dict, int]:
286288
return {"sources": sources}, 200
287289

288290

291+
@agentic_ns.route("/tiers")
292+
class TiersResource(Resource):
293+
"""The search-budget tiers and the model preset each one runs on."""
294+
295+
@agentic_ns.doc("list_tiers")
296+
@agentic_ns.response(200, "Tier ids mapped to the model each tier runs on.")
297+
@agentic_ns.response(401, "Missing or invalid API key.")
298+
def get(self) -> tuple[dict, int]:
299+
"""List the search tiers and their model presets.
300+
301+
A tier is the run's one budget knob — decomposition depth and probe
302+
fan-out — and it also presets the model that drives the run
303+
(`scg.traversal.tier_models`). A blank mapping resolves to
304+
`llm.default_model`, mirroring exactly what the drive does, so the
305+
returned model is the one that actually runs unless the request
306+
carries an explicit `model` override. Pure config read — available
307+
regardless of `scg.enabled`.
308+
"""
309+
if (auth := _require_api_key()) is not None:
310+
return auth
311+
default_model = str(get_config_value("llm", "default_model") or "")
312+
return {
313+
"default_tier": ScgConfig.default_tier(),
314+
"tiers": {
315+
tier: ScgConfig.model_for_tier(tier) or default_model
316+
for tier in SEARCH_TIERS
317+
},
318+
}, 200
319+
320+
289321
# -- Workspaces ------------------------------------------------------------
290322

291323

apps/mewbo_console/src/__tests__/searchBarAutocomplete.test.tsx

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
*/
1010
import { afterEach, describe, expect, it, vi } from "vitest"
1111
import { cleanup, fireEvent, render, screen } from "@testing-library/react"
12+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
1213

1314
afterEach(cleanup)
1415

@@ -37,19 +38,25 @@ const wsB: Workspace = {
3738
}
3839

3940
function renderBar(over: Partial<React.ComponentProps<typeof SearchBar>> = {}) {
41+
// SearchBar reads the tier→model presets via useTiers (TanStack Query), so
42+
// a provider is required; queries fail fast (retry: false) and the bar
43+
// renders its graceful fallbacks — these tests don't depend on tiers data.
44+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
4045
return render(
41-
<SearchBar
42-
value=""
43-
onChange={vi.fn()}
44-
onSubmit={vi.fn()}
45-
workspace={wsA}
46-
workspaces={[wsA, wsB]}
47-
onPickWorkspace={vi.fn()}
48-
onNewWorkspace={vi.fn()}
49-
variant="hero"
50-
autoFocus
51-
{...over}
52-
/>,
46+
<QueryClientProvider client={qc}>
47+
<SearchBar
48+
value=""
49+
onChange={vi.fn()}
50+
onSubmit={vi.fn()}
51+
workspace={wsA}
52+
workspaces={[wsA, wsB]}
53+
onPickWorkspace={vi.fn()}
54+
onNewWorkspace={vi.fn()}
55+
variant="hero"
56+
autoFocus
57+
{...over}
58+
/>
59+
</QueryClientProvider>,
5360
)
5461
}
5562

apps/mewbo_console/src/api/agenticSearch.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
ScgStatus,
1515
SearchEvent,
1616
SearchTier,
17+
SearchTiersInfo,
1718
SourceCatalogEntry,
1819
Workspace,
1920
WorkspaceInput,
@@ -56,6 +57,15 @@ export async function listSources(): Promise<SourceCatalogEntry[]> {
5657
return payload.sources
5758
}
5859

60+
/** `GET /tiers` — the search-budget tiers + the model preset each runs on
61+
* (resolved server-side exactly like the drive: tier map → llm default). */
62+
export async function fetchTiers(): Promise<SearchTiersInfo> {
63+
const res = await fetch(withBase("/api/agentic_search/tiers"), {
64+
headers: jsonHeaders(),
65+
})
66+
return readJson<SearchTiersInfo>(res)
67+
}
68+
5969
export async function listWorkspaces(): Promise<Workspace[]> {
6070
const res = await fetch(withBase("/api/agentic_search/workspaces"), {
6171
headers: jsonHeaders(),

apps/mewbo_console/src/components/agentic_search/AgenticSearchView.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,14 @@ export default function AgenticSearchView() {
6565
// trialled for one search session without a config edit or server restart;
6666
// a reload restores the configured tier→model mapping.
6767
const [model, setModel] = useState("")
68+
// Picking a tier selects the whole preset — budget AND model — so it CLEARS
69+
// any model override; the model pill then names the new tier's preset and
70+
// the user deviates from there if they want to. Without the reset, a stale
71+
// override from a previous tier would silently win over the fresh pick.
72+
const handleTierChange = useCallback((next: SearchTier) => {
73+
setTier(next)
74+
setModel("")
75+
}, [])
6876

6977
// URL IS THE SINGLE SOURCE OF TRUTH for {workspace, active run} (#80).
7078
// Canonical shape: `/search?ws=<workspace_id>&run=<run_id>`. Both facets are
@@ -353,7 +361,7 @@ export default function AgenticSearchView() {
353361
isLoading={submitting || (Boolean(runId) && runQuery.isLoading && !stream.attached)}
354362
submitting={submitting}
355363
tier={tier}
356-
onTierChange={setTier}
364+
onTierChange={handleTierChange}
357365
model={model}
358366
onModelChange={setModel}
359367
onRun={handleSubmit}
@@ -395,7 +403,7 @@ export default function AgenticSearchView() {
395403
workspaces={workspaces}
396404
sources={sources}
397405
tier={tier}
398-
onTierChange={setTier}
406+
onTierChange={handleTierChange}
399407
model={model}
400408
onModelChange={setModel}
401409
submitting={submitting}

apps/mewbo_console/src/components/agentic_search/CLAUDE.md

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -148,25 +148,46 @@ MCP-integration-sourced).
148148
vendored and `@radix-ui/react-tooltip` isn't installed;
149149
`unavailable_reason` follows the idiom.
150150

151-
## Tier + model pickers — threaded top-down
151+
## Tier + model pickers — one coupled preset, two pills
152+
153+
The two pills describe ONE backend resolution — `run.model or the tier's
154+
configured model` — and the UI must keep that legible (they are NOT two
155+
entry points for the same datum; the tier is the budget knob (decomposition
156+
depth + probe fan-out) that ALSO presets the model, the model pill is the
157+
per-run deviation from that preset):
158+
159+
- **The budget dropdown names what a tier buys.** `TierPill` rows are
160+
two-line: name + a depth/fan-out hint (prose, never mono) over the tier's
161+
model preset (mono — it's data; `ModelBrandIcon` + `formatModelName`),
162+
under a "Search budget — depth · fan-out · model" label. Presets come
163+
from `useTiers()` (`GET /tiers`, fetched once in `SearchBar` — config-
164+
backed, 5 min staleTime); rows omit the model line until it resolves —
165+
never fabricate a placeholder.
166+
- **The model pill's default label NAMES the tier preset**`defaultLabel`
167+
becomes `"Auto · claude-sonnet-4-6"` (tier name + formatted preset), so
168+
the resolved model is always visible at rest; it falls back to "Default
169+
model" only while tiers are unresolved. The pill reuses the wiki
170+
`ModelPicker` (compact variant; the additive `title` prop is the hover
171+
explainer) styled via `MODEL_PILL_CLASS` `className` overrides (`cn`
172+
later-wins strips the picker's border/bg) — don't fork it.
173+
- **Picking a tier selects the whole preset → it CLEARS the override.**
174+
`AgenticSearchView.handleTierChange` wraps `setTier` + `setModel("")`;
175+
without the reset a stale override from a previous tier silently wins
176+
over the fresh pick. The user then deviates from the named preset via
177+
the model pill if they want to.
152178

153179
`AgenticSearchView` owns the persisted tier (localStorage
154180
`agentic-search:tier`, validated against the literal list on read) and
155-
passes `tier`/`onTierChange` to both panels' `SearchBar`; the pill renders
156-
only when both props are present so legacy call sites stay valid. Sent as
157-
`tier` on the `POST /runs` body — never a verification knob.
158-
159-
The sibling model pill reuses the wiki `ModelPicker` (compact variant +
160-
its `defaultLabel` prop — the "Default" row tops the list, the
161-
task-composer pattern; selecting it emits `""` = "tier picks"). The pill
162-
matches `TierPill`'s visual language via `className` overrides
163-
(`MODEL_PILL_CLASS``cn` later-wins strips the picker's border/bg);
164-
don't fork the component for styling. The override is **DELIBERATELY
165-
session-instance-only** (plain `useState("")`, no localStorage): its
166-
purpose is trialling a custom model for one search without a config edit
167-
or server restart, and a reload restores the configured tier→model
168-
mapping. Sent as `model` on `POST /runs` only when non-empty; the BE
169-
echoes it on `RunPayload.model`.
181+
passes `tier`/`onTierChange` to both panels' `SearchBar`; the pills render
182+
only when their props are present so legacy call sites stay valid. The
183+
model override is **DELIBERATELY session-instance-only** (plain
184+
`useState("")`, no localStorage): its purpose is trialling a custom model
185+
for one search without a config edit or server restart; a reload restores
186+
the configured tier→model mapping. Sent as `tier` / non-empty `model` on
187+
`POST /runs`; the BE echoes `RunPayload.model`. `SearchBar.test.tsx` locks
188+
the coupled-label + budget-row contract (Radix menu opened via keyboard —
189+
the reliable jsdom path); any test mounting `SearchBar` now needs a
190+
`QueryClientProvider` (useTiers).
170191

171192
## Snippet rendering is injection-safe by construction
172193

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/**
2+
* SearchBar coupled tier/model picker tests.
3+
*
4+
* The composer's two pills describe ONE resolution — `run.model or the
5+
* tier's preset` — so the UI must keep them legible as a pair:
6+
* 1. The model pill's default label NAMES the current tier's preset
7+
* ("Auto · claude-sonnet-4-6"), never a vague "Default model", once
8+
* `GET /tiers` resolves.
9+
* 2. An explicit override replaces that label with the chosen model.
10+
* 3. The budget dropdown rows carry the budget hints (depth · fan-out) AND
11+
* each tier's model preset, so picking a tier reads as picking the whole
12+
* preset.
13+
*
14+
* vitest runs WITHOUT globals, so cleanup is wired explicitly (console
15+
* convention). useTiers/useModels are mocked at the hook seam — the only I/O
16+
* boundary — everything else renders the real component tree.
17+
*/
18+
import { afterEach, describe, expect, it, vi } from "vitest"
19+
import { cleanup, fireEvent, render, screen } from "@testing-library/react"
20+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
21+
22+
import { SearchBar } from "./SearchBar"
23+
import type { Workspace } from "../../types/agenticSearch"
24+
25+
vi.mock("../../hooks/useAgenticSearch", async (importOriginal) => {
26+
const mod = await importOriginal<typeof import("../../hooks/useAgenticSearch")>()
27+
return {
28+
...mod,
29+
useTiers: () => ({
30+
data: {
31+
default_tier: "auto",
32+
tiers: {
33+
fast: "openai/gpt-5.4-nano",
34+
auto: "anthropic/claude-sonnet-4-6",
35+
deep: "openai/gpt-5.5",
36+
},
37+
},
38+
}),
39+
}
40+
})
41+
42+
vi.mock("../../hooks/useModels", () => ({
43+
useModels: () => ({
44+
models: ["openai/gpt-5.5", "anthropic/claude-sonnet-4-6"],
45+
defaultModel: "",
46+
capabilities: {},
47+
loading: false,
48+
error: null,
49+
refresh: vi.fn(),
50+
}),
51+
}))
52+
53+
afterEach(cleanup)
54+
55+
const MODEL_PILL_TITLE = "Model for this run — overrides the tier's preset"
56+
57+
function workspace(): Workspace {
58+
return {
59+
id: "w1",
60+
name: "Platform",
61+
desc: "Infra and CI",
62+
sources: ["github"],
63+
instructions: "",
64+
created: "today",
65+
past_queries: [],
66+
}
67+
}
68+
69+
function renderBar(over: Partial<Parameters<typeof SearchBar>[0]> = {}) {
70+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
71+
return render(
72+
<QueryClientProvider client={qc}>
73+
<SearchBar
74+
value=""
75+
onChange={vi.fn()}
76+
onSubmit={vi.fn()}
77+
workspace={workspace()}
78+
workspaces={[workspace()]}
79+
onPickWorkspace={vi.fn()}
80+
onNewWorkspace={vi.fn()}
81+
tier="auto"
82+
onTierChange={vi.fn()}
83+
model=""
84+
onModelChange={vi.fn()}
85+
{...over}
86+
/>
87+
</QueryClientProvider>,
88+
)
89+
}
90+
91+
describe("SearchBar coupled tier/model pickers", () => {
92+
it("model pill default label names the current tier's preset", () => {
93+
renderBar()
94+
const pill = screen.getByTitle(MODEL_PILL_TITLE)
95+
expect(pill).toHaveTextContent("Auto · claude-sonnet-4-6")
96+
})
97+
98+
it("default label follows the tier prop (Deep names the deep preset)", () => {
99+
renderBar({ tier: "deep" })
100+
expect(screen.getByTitle(MODEL_PILL_TITLE)).toHaveTextContent("Deep · gpt-5.5")
101+
})
102+
103+
it("an explicit override replaces the preset label", () => {
104+
renderBar({ model: "openai/gpt-5.5" })
105+
const pill = screen.getByTitle(MODEL_PILL_TITLE)
106+
expect(pill).toHaveTextContent("gpt-5.5")
107+
expect(pill).not.toHaveTextContent("Auto ·")
108+
})
109+
110+
it("budget dropdown rows show depth/fan-out hints + per-tier model presets", () => {
111+
renderBar()
112+
const trigger = screen.getByRole("button", { name: "Search budget" })
113+
// Radix DropdownMenu opens from keyboard on the trigger — the reliable
114+
// jsdom path (no PointerEvent capture semantics needed).
115+
fireEvent.keyDown(trigger, { key: "Enter" })
116+
expect(screen.getByText("Search budget — depth · fan-out · model")).toBeInTheDocument()
117+
// Budget hints speak depth × probes, not speed adjectives.
118+
expect(screen.getByText("shallow · few probes")).toBeInTheDocument()
119+
expect(screen.getByText("max depth · wide fan-out")).toBeInTheDocument()
120+
// Each tier row names the model preset it runs on.
121+
expect(screen.getByText("gpt-5.4-nano")).toBeInTheDocument()
122+
expect(screen.getByText("claude-sonnet-4-6")).toBeInTheDocument()
123+
})
124+
125+
it("picking a tier row emits onTierChange with the tier id", () => {
126+
const onTierChange = vi.fn()
127+
renderBar({ onTierChange })
128+
fireEvent.keyDown(screen.getByRole("button", { name: "Search budget" }), { key: "Enter" })
129+
fireEvent.click(screen.getByText("max depth · wide fan-out"))
130+
expect(onTierChange).toHaveBeenCalledWith("deep")
131+
})
132+
})

0 commit comments

Comments
 (0)