Skip to content

Commit f73b32d

Browse files
committed
refactor(search): drop locale from POST /v1/search body
Public API accepts only { query }. Prompt uses query language only. Cache keys no longer include locale; remove meta.locale_requested. Update OpenAPI, smoke script, PRD, contract test. Made-with: Cursor
1 parent 1b2f3d5 commit f73b32d

6 files changed

Lines changed: 20 additions & 39 deletions

File tree

docker/search/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
Production image `spawndock/search:prod`: Qwen Code CLI + HTTP server on port **8790**.
44

55
- **Browser:** `GET /knowledge/` — Swagger UI (OpenAPI from `/knowledge/openapi.yaml`).
6-
- **REST:** `GET /knowledge/api/v1/health`, `POST /knowledge/api/v1/search` ({ `query`, optional `locale` }).
6+
- **REST:** `GET /knowledge/api/v1/health`, `POST /knowledge/api/v1/search` (JSON `{ "query" }` only).
77
- **MCP:** set `SEARCH_REST_API=true` and `SEARCH_API_URL=http://search:8790` on `mcp-server`.
88

99
**Rate limits** apply only when `X-Forwarded-For` is set (traffic via Caddy). Direct calls from Docker (e.g. MCP) skip public quotas. Override tiers with JSON env `SEARCH_RATE_LIMIT_TIERS` (see `DEFAULT_TIERS` in `http-server.mjs`).

docker/search/http-server.mjs

Lines changed: 8 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -62,21 +62,7 @@ function resolveKnowledgeRoot() {
6262
return "";
6363
}
6464

65-
function localeDirective(locale) {
66-
const l = (locale || "").trim().toLowerCase();
67-
if (!l) {
68-
return "Write the answer in the same language as the user query.";
69-
}
70-
if (l === "ru" || l.startsWith("ru-")) {
71-
return "You MUST write the entire answer in Russian.";
72-
}
73-
if (l === "en" || l.startsWith("en-")) {
74-
return "You MUST write the entire answer in English.";
75-
}
76-
return `You MUST write the entire answer in the primary language for locale ${locale} (BCP 47).`;
77-
}
78-
79-
function buildSearchPrompt(query, locale, matches) {
65+
function buildSearchPrompt(query, matches) {
8066
const context =
8167
matches.length > 0
8268
? matches
@@ -89,7 +75,7 @@ function buildSearchPrompt(query, locale, matches) {
8975

9076
return [
9177
"You are a knowledge assistant for Telegram Mini App (TMA) and SpawnDock documentation.",
92-
localeDirective(locale),
78+
"Write the answer in the same language as the user query.",
9379
"When excerpts below are relevant, base your answer strictly on them.",
9480
'Respond with valid JSON only (no markdown fences): {"answer":"...","sources":[{"file":"path.md","section":"Heading"}]}',
9581
"List every excerpt source you used in \"sources\"; use [] only if excerpts were not used.",
@@ -113,10 +99,9 @@ function normalizeQueryForCache(q) {
11399
return q.trim().toLowerCase().replace(/\s+/g, " ");
114100
}
115101

116-
function searchCacheKey(knowledgeRoot, locale, query) {
102+
function searchCacheKey(knowledgeRoot, query) {
117103
const mtime = knowledgeRoot ? corpusMaxMtime(knowledgeRoot) : 0;
118-
const loc = (locale ?? "").trim().toLowerCase();
119-
return `${KNOWLEDGE_CACHE_REVISION}|${mtime}|${loc}|${normalizeQueryForCache(query)}`;
104+
return `${KNOWLEDGE_CACHE_REVISION}|${mtime}|${normalizeQueryForCache(query)}`;
120105
}
121106

122107
function searchCacheGet(key) {
@@ -315,9 +300,9 @@ function normalizeSearchBody(rawText) {
315300
}
316301
}
317302

318-
async function runSearchQuery(query, locale) {
303+
async function runSearchQuery(query) {
319304
const knowledgeRoot = resolveKnowledgeRoot();
320-
const cacheKey = searchCacheKey(knowledgeRoot, locale, query);
305+
const cacheKey = searchCacheKey(knowledgeRoot, query);
321306
const cached = searchCacheGet(cacheKey);
322307
if (cached) {
323308
return {
@@ -334,15 +319,14 @@ async function runSearchQuery(query, locale) {
334319
console.error("knowledge rank error:", err instanceof Error ? err.message : err);
335320
}
336321
}
337-
const prompt = buildSearchPrompt(query, locale, matches);
322+
const prompt = buildSearchPrompt(query, matches);
338323
const stdout = await runQwenPrompt(prompt);
339324
const normalized = normalizeSearchBody(extractQwenCliResult(stdout));
340325
let sources = normalized.sources;
341326
if (sources.length === 0 && matches.length > 0) {
342327
sources = matches.map((m) => ({ file: m.file, section: m.section }));
343328
}
344329
const meta = {};
345-
if (locale) meta.locale_requested = locale;
346330
const result = { answer: normalized.answer, sources, meta };
347331
searchCacheSet(cacheKey, result);
348332
return result;
@@ -446,14 +430,13 @@ async function handleRequest(req, res) {
446430
return;
447431
}
448432
const query = typeof json.query === "string" ? json.query : "";
449-
const locale = typeof json.locale === "string" ? json.locale : undefined;
450433
if (!query.trim()) {
451434
sendError(res, 400, "validation_error", "Field \"query\" is required and must be non-empty");
452435
return;
453436
}
454437

455438
try {
456-
const result = await runSearchQuery(query, locale);
439+
const result = await runSearchQuery(query);
457440
sendJson(res, 200, result);
458441
} catch (err) {
459442
const message = err instanceof Error ? err.message : String(err);

docs/PRD-public-knowledge-search-service.md

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
- Прод-стек: **Caddy** отдаёт **`/knowledge*`** на контейнер **`search`** (внутренний HTTP, порт **`8790`**); остальной трафик — на **`mcp-server:3000`** по существующим правилам.
4848
- **Сервис Compose:** имя **`search`**, образ **`spawndock/search:prod`**; каталог корпуса **`./knowledge:/corpus:ro`**; переменная **`KNOWLEDGE_ROOT=/corpus`** (см. `docker-compose.prod.yml`).
4949
- **MCP (`mcp-server`):** при **`SEARCH_REST_API=true`** вызывает **`SEARCH_API_URL`** (например `http://search:8790`) на **`POST /knowledge/api/v1/search`**; внутрисетевые запросы **без** `X-Forwarded-For` **не** попадают под публичный rate limit (**§5.4.6** реализовано).
50-
- **Пайплайн ответа:** локальное ранжирование фрагментов из корпуса (Markdown под `KNOWLEDGE_ROOT`), подстановка выдержек и директивы **`locale`** в промпт **Qwen Code CLI**; поля **`sources`** заполняются из ответа модели либо fallback по ранжированным файлам. Аутентификация Qwen — OAuth (`PROD_QWEN_OAUTH_CREDS` / `QWEN_OAUTH_CREDS_B64``~/.qwen/oauth_creds.json` в entrypoint).
50+
- **Пайплайн ответа:** локальное ранжирование фрагментов из корпуса (Markdown под `KNOWLEDGE_ROOT`), подстановка выдержек в промпт **Qwen Code CLI**; язык ответа — **по языку запроса** (отдельного поля `locale` в API **нет**). Поля **`sources`** из JSON модели либо fallback по ранжированным файлам. Аутентификация Qwen — OAuth (`PROD_QWEN_OAUTH_CREDS` / `QWEN_OAUTH_CREDS_B64``~/.qwen/oauth_creds.json` в entrypoint).
5151
- Токен **`API_TOKEN`** выдаётся оператором/ботом и кладётся в `.env` на **`search`** и связанные сервисы; невалидный Bearer → **401**.
5252

5353
## 5. Target architecture
@@ -155,13 +155,12 @@ Caddy по-прежнему монтирует весь продукт под п
155155

156156
| Поле | Тип | Обязательность | Описание |
157157
|------|-----|----------------|----------|
158-
| `query` | string | **да** | Текст запроса к базе знаний; **без** полей, меняющих способ формирования ответа «снаружи» |
159-
| `locale` | string | нет | Предпочитаемая локаль ответа (например `en`, `ru`) — если сервис поддерживает; при отсутствии — поведение по умолчанию |
158+
| `query` | string | **да** | Текст запроса к базе знаний |
160159

161160
Пример:
162161

163162
```json
164-
{ "query": "How do I wire a Telegram Mini App webhook?", "locale": "en" }
163+
{ "query": "How do I wire a Telegram Mini App webhook?" }
165164
```
166165

167166
**Ответ 200 (JSON):**
@@ -170,7 +169,7 @@ Caddy по-прежнему монтирует весь продукт под п
170169
|------|-----|----------|
171170
| `answer` | string | Основной текст ответа |
172171
| `sources` | array | **Опционально.** Унифицированный список источников из корпуса (например `{ "path": "guides/foo.md", "title": "..." }`) — структура задаётся в OpenAPI `components.schemas` |
173-
| `meta` | object | **Опционально.** Служебные не секретные поля (например `locale_requested`, `cache_hit` при попадании в LRU-кэш **`search`**) |
172+
| `meta` | object | **Опционально.** Служебные не секретные поля (например `cache_hit` при попадании в LRU-кэш **`search`**) |
174173

175174
Нормативные детали полей **`sources`** / **`meta`** — только в OpenAPI; клиент опирается на спецификацию, без знания внутренней реализации.
176175

@@ -271,7 +270,7 @@ rate_limit_tiers:
271270
|-----------|--------|---------|
272271
| Монтирование корпуса **`/corpus`** (прод) и **`KNOWLEDGE_ROOT`** | **Done** | Образ также содержит снимок **`knowledge/`** в **`/opt/search/knowledge`** для запуска без volume. |
273272
| Ранжирование фрагментов перед вызовом LLM | **Done** | По умолчанию **[MiniSearch](https://github.com/lucaong/minisearch)** (BM25-алгоритм) по секциям Markdown; при пустом hit — fallback на эвристику по токенам. `SEARCH_RANKER=legacy` — только эвристика. См. `docker/search/knowledge-rank.mjs`; зеркало эвристики в `src/local-search.ts`. |
274-
| Учёт **`locale`** в промпте | **Done** | Явные инструкции `ru` / `en` / авто по языку запроса. |
273+
| Язык ответа | **Done** | Только по тексту **`query`** (инструкция модели: совпадать с языком запроса); параметра **`locale`** нет. |
275274
| Поле **`sources`** | **Done** | Из JSON ответа модели; если пусто — fallback из ранжированных источников. |
276275
| Диагностика сбоев Qwen (**stdout/stderr** в **502**) | **Done** | Усечённые потоки в `message` для оператора. |
277276
| Совпадение ранжирования MCP и **search** | **Done** | Оба пути: MiniSearch по секциям + legacy fallback; `SEARCH_RANKER=legacy` для отката. |
@@ -291,7 +290,7 @@ rate_limit_tiers:
291290
| `SEARCH_HTTP_BIND` / `QWEN_HTTP_BIND` | Bind address (по умолчанию **0.0.0.0**). |
292291
| `KNOWLEDGE_ROOT` | Корень Markdown-корпуса (**рекомендуется `/corpus`** в проде). |
293292
| `SEARCH_RANKER` | `minisearch` (по умолчанию) или `legacy` — только эвристика по токенам (MCP `local-search` и sidecar). |
294-
| `SEARCH_RESPONSE_CACHE_MAX` | LRU-кэш готовых JSON-ответов **search** (ключ: ревизия + mtime корпуса + locale + query). **`0`** — выкл. |
293+
| `SEARCH_RESPONSE_CACHE_MAX` | LRU-кэш готовых JSON-ответов **search** (ключ: ревизия + mtime корпуса + нормализованный `query`). **`0`** — выкл. |
295294
| `KNOWLEDGE_CACHE_REVISION` | Произвольная строка для инвалидации кэша без смены файлов на диске. |
296295
| `SEARCH_RATE_LIMIT_TIERS` | JSON override лимитов **free** / **basic** (см. §5.6). |
297296
| `API_TOKEN` | Общий секрет для **Bearer** и tier **basic** на **`search`**. |
@@ -368,7 +367,7 @@ rate_limit_tiers:
368367
| ID | Требование | Приоритет |
369368
|----|------------|-----------|
370369
| **NR-RET-1** | ~~Оценить BM25~~**частично done** (MiniSearch + секции). Далее: **FTS5 / RRF / trigram** при необходимости; контракт API без изменений. | P2 |
371-
| **NR-RET-2** | **Частично done:** in-memory LRU в **`search`** (`SEARCH_RESPONSE_CACHE_MAX`, mtime корпуса + `KNOWLEDGE_CACHE_REVISION`). Далее: shared store при нескольких репликах. | P3 |
370+
| **NR-RET-2** | **Частично done:** in-memory LRU в **`search`** (`SEARCH_RESPONSE_CACHE_MAX`, mtime корпуса + `KNOWLEDGE_CACHE_REVISION` + query). Далее: shared store при нескольких репликах. | P3 |
372371
| **NR-OBS-1** | Метрики (**accepted/429/latency/502**) и точки интеграции с мониторингом хоста. | P2 |
373372
| **NR-HA-1** | При **>1 реплики** `search` — вынести дневные/минутные счётчики rate limit из in-memory (**Redis** и аналоги); см. §5.6.3. | P2 |
374373
| **NR-TEST-1** | CI: e2e контейнер **`search`** + health + search с моком Qwen или dry-run режимом. | P3 |
@@ -393,4 +392,4 @@ rate_limit_tiers:
393392

394393
---
395394

396-
*Document version: 1.7 — 2026-03-25 — MCP `local-search` + MiniSearch; кэш ответов `search`; §6 cache env; meta `cache_hit`.*
395+
*Document version: 1.8 — 2026-03-25 — убран параметр **`locale`** из **`POST /v1/search`**; язык только по **`query`**.*

openapi/knowledge-v1.yaml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,7 @@ paths:
4949
properties:
5050
query:
5151
type: string
52-
locale:
53-
type: string
54-
description: Preferred response locale (e.g. en, ru)
52+
description: Natural-language question; response language follows the query language.
5553
responses:
5654
"200":
5755
description: Search result

scripts/smoke-knowledge-search.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ async function main() {
4242
const searchRes = await fetch(`${base}/api/v1/search`, {
4343
method: "POST",
4444
headers,
45-
body: JSON.stringify({ query: "SpawnDock TMA", locale: "en" }),
45+
body: JSON.stringify({ query: "SpawnDock TMA" }),
4646
});
4747
const body = await searchRes.text();
4848
if (!searchRes.ok) {

src/__tests__/knowledge-openapi-contract.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@ describe("knowledge OpenAPI contract", () => {
1414
expect(yaml).toContain("/v1/search");
1515
expect(yaml).toContain("servers:");
1616
expect(yaml).toContain("/knowledge/api");
17+
expect(yaml).not.toContain("locale");
1718
});
1819
});

0 commit comments

Comments
 (0)