API Reference¶
All endpoints are served under http://<host>:8000. Interactive, always-current
schemas are generated by FastAPI and are the authoritative source of truth (they
never drift from the code):
- Swagger UI —
/docs - ReDoc —
/redoc - OpenAPI JSON —
/openapi.json
This page is a hand-curated, self-contained reference: every endpoint below carries a
concrete request body, response body, and the status codes it can return — enough to
integrate without reading the source. Models live in src/kb/models/ and the
per-endpoint handlers in src/kb/api/.
Conventions¶
- Base path — all business endpoints are under
/api/v1. Meta endpoints (/healthz,/readyz,/metrics,/docs) sit at the root. - Content type — request and response bodies are
application/jsonunless noted (file upload ismultipart/form-data). - Validation — request bodies are validated by pydantic; a malformed body returns 422 with a field-level error list (FastAPI's default envelope). These per-field 422s are not repeated in each table below.
- No authentication — the service ships without authN/Z; deploy it behind a gateway or reverse proxy that enforces access (see Security).
- Feature gating — chat, extract, and ingest require
KB_LLM__API_KEY; without it they return 503. Vector ranking degrades to BM25 whenKB_EMBEDDING__API_KEYis unset (search still returns 200). See Configuration.
Search¶
POST /api/v1/search¶
Structured hybrid search (src/kb/api/search.py → src/kb/services/search.py). The
body is a fully-extracted SearchRequest — this endpoint does not parse natural
language (that is /extract and /chat).
Request — SearchRequest
| Field | Type | Default | Notes |
|---|---|---|---|
knowledge_type |
alarm\|setup\|experience\|null |
null |
null searches all types |
project |
str\|null (≤200) |
null |
Exact-match filter (taxonomy value) |
equipment |
str\|null (≤200) |
null |
Exact-match filter (taxonomy value) |
error_codes |
list[str] (≤64 items, each ≤64 chars) |
[] |
Doc matches if it contains any one |
keywords |
list[str] (≤64 items, each ≤200 chars) |
[] |
BM25 keywords |
query_text |
str\|null (≤4000) |
null |
Raw text for the vector pass |
mode |
auto\|strict\|loose\|vector_only |
auto |
Retrieval cascade selector |
size |
int (1–50) |
10 |
Page size |
from_ |
int (≥0) |
0 |
Offset; from_ + size must be ≤ 10000 |
{
"knowledge_type": "alarm",
"project": "PDX",
"equipment": "Aligner",
"error_codes": ["E-1234"],
"keywords": ["真空", "泄漏"],
"query_text": "真空泄漏报警如何处理",
"mode": "auto",
"size": 5,
"from_": 0
}
Response — SearchResponse
| Field | Type | Notes |
|---|---|---|
status |
SearchStatus |
One of strict_hit\|too_many\|loose_hit\|vector_only\|no_hit |
total |
int |
Total matches for the winning stage |
hits |
list[DocHit] |
Verbatim documents (see below) |
effective_params |
EffectiveParams |
Echo of the filters/keywords actually applied |
facets |
dict[str, dict[str,int]] |
Present only on too_many — value→count per facet |
facets_truncated |
dict[str,int] |
Per-facet count of values beyond the shown buckets |
banner |
str\|null |
Must be rendered verbatim on loose_hit/vector_only/no_hit |
A DocHit is { id, score, knowledge_type, project, equipment, error_codes[], title,
source_file?, source_pages[], summary?, sections{} }. sections holds the original
content fields verbatim — never AI-rewritten.
{
"status": "strict_hit",
"total": 2,
"hits": [
{
"id": "kb_alarm_v1:9f2c…",
"score": 14.21,
"knowledge_type": "alarm",
"project": "PDX",
"equipment": "Aligner",
"error_codes": ["E-1234"],
"title": "真空泄漏报警(Vacuum Leak)",
"source_file": "alarms_2024.pptx",
"source_pages": ["12"],
"summary": "真空泄漏报警处理",
"sections": {
"content": "报警触发条件……",
"resolution": "1. 检查密封圈……",
"notes": "复位前确认……"
}
}
],
"effective_params": {
"knowledge_type": "alarm",
"project": "PDX",
"equipment": "Aligner",
"error_codes": ["E-1234"],
"keywords": ["真空", "泄漏"]
},
"facets": {},
"facets_truncated": {},
"banner": null
}
The status values are a cross-system contract — see the
status contract for what each means and
how a caller must render it. The retrieval cascade and the BM25 + vector blend formula
are documented in Search & Ranking.
Status codes
| Code | When |
|---|---|
200 |
Always, for any successful search (including no_hit) |
422 |
from_ + size > 10000, or any field out of bounds |
Chat & extract¶
Both require KB_LLM__API_KEY (else 503). Full design:
AI Chat Search.
POST /api/v1/chat¶
Conversational search. The server is stateless — the client sends the full message history every turn. Internally: summarize old turns → extract params → search → answer grounded in results.
Request — ChatRequest
| Field | Type | Notes |
|---|---|---|
messages |
list[{role, content}] (1–200 msgs, content ≤20 000 chars) |
Full conversation, oldest→newest |
last_search_params |
dict\|null |
Echo the previous response's effective_params to enable update mode (incremental param edits) |
{
"messages": [
{"role": "user", "content": "PDX 的 Aligner 真空泄漏报警怎么处理?"}
],
"last_search_params": {
"project": "PDX", "equipment": "Aligner",
"knowledge_type": "alarm", "error_codes": [], "keywords": ["真空", "泄漏"]
}
}
Response — ChatResponse
| Field | Type | Notes |
|---|---|---|
content |
str |
Markdown answer, grounded only in retrieved docs |
search_results |
list[DocHit]\|null |
The hits used as context (null if no search ran) |
search_status |
SearchStatus\|null |
Status of the internal search |
effective_params |
EffectiveParams\|null |
Echo this back as last_search_params next turn |
search_error |
bool |
true when retrieval failed (e.g. ES down) — distinct from a genuine no_hit; show a retry hint, not "no knowledge found" |
{
"content": "根据知识库中的 1 条文档……\n\n1. 检查密封圈……",
"search_results": [ { "id": "kb_alarm_v1:9f2c…", "title": "真空泄漏报警(Vacuum Leak)", "...": "..." } ],
"search_status": "strict_hit",
"effective_params": {
"knowledge_type": "alarm", "project": "PDX", "equipment": "Aligner",
"error_codes": [], "keywords": ["真空", "泄漏"]
},
"search_error": false
}
Status codes
| Code | When |
|---|---|
200 |
Answer produced (even on no_hit or search_error: true) |
502 |
LLM upstream returned an error or unparseable output |
503 |
KB_LLM__API_KEY not set |
POST /api/v1/extract¶
Natural language → structured params. The LLM is primed with the live taxonomy, and any
value not in the taxonomy is dropped to null (an unknown filter would silently match
nothing).
Request — ExtractRequest: { "query": "PDX aligner 真空泄漏 E-1234" } (1–20 000 chars).
Response — ExtractResponse
{
"project": "PDX",
"knowledge_type": "alarm",
"error_codes": ["E-1234"],
"equipment": "Aligner",
"keywords": ["真空", "泄漏"],
"is_sentence": false
}
is_sentence is true for a natural-language question, false for a keyword bag — the
caller can use it to decide whether to also pass query_text to /search.
Status codes: 200 ok · 502 unparseable LLM output · 503 no LLM key.
Documents¶
Direct CRUD into the type indices (src/kb/api/documents.py). {knowledge_type} is
validated against the enum; the payload is discriminated to the matching
AlarmDoc/SetupDoc/ExperienceDoc model and validated against the taxonomy. Field
schemas per type are in the Data Model.
| Method & path | Purpose | Success | Errors |
|---|---|---|---|
GET /api/v1/documents/stats |
Counts by type/project/equipment/error code (powers the landing page) | 200 |
— |
POST /api/v1/documents/{knowledge_type} |
Index one document | 201 {id} |
400 invalid doc / taxonomy |
POST /api/v1/documents/{knowledge_type}/_bulk |
Bulk index (array body) | 200 {indexed, errors[]} |
parse errors returned in body with indexed:0 |
DELETE /api/v1/documents/{knowledge_type}/{doc_id} |
Delete by id | 204 |
404 if absent |
POST …/{knowledge_type} body (alarm example) and response:
// request
{
"project": "PDX", "equipment": "Aligner",
"title": "真空泄漏报警(Vacuum Leak)", "error_codes": ["E-1234"],
"content": "报警触发条件……", "resolution": "1. 检查密封圈……", "notes": ""
}
// 201 response
{ "id": "kb_alarm_v1:9f2c…" }
GET /api/v1/documents/stats returns:
{
"total": 412,
"by_type": { "alarm": 210, "setup": 120, "experience": 82 },
"by_project": { "PDX": 96, "MEM": 88, "…": 0 },
"by_equipment": { "Aligner": 54, "Pump": 40 },
"by_error_code": { "E-1234": 7 }
}
The _bulk endpoint validates all rows first; if any row fails to parse it returns
{ "indexed": 0, "errors": [{"row": 3, "error": "…"}] } without indexing anything
(all-or-nothing on parse). Rows that parse but fail at index time appear in errors with
the bulk run's per-row outcome.
Ingest (file import)¶
Review-gated file → document pipeline (src/kb/api/ingest.py). Requires
KB_LLM__API_KEY (segmentation uses the LLM). Full design:
Import Pipeline. All work is async: upload/scan/retry
return 202 immediately with a session_id; you then poll the session until its
status is ready_for_review.
Endpoints¶
| Method & path | Purpose | Success |
|---|---|---|
POST /api/v1/ingest/upload |
Multipart upload of one or more files | 202 UploadResponse |
POST /api/v1/ingest/scan |
Scan a server-side folder under ingest.scan_root |
202 UploadResponse |
GET /api/v1/ingest/sessions?limit=20 |
List recent sessions | 200 SessionListItem[] |
GET /api/v1/ingest/sessions/{id} |
Inspect a session (poll this) | 200 SessionResponse |
GET /api/v1/ingest/sessions/{id}/summary |
Pre-commit consequence counts | 200 CommitSummary |
PUT /api/v1/ingest/sessions/{id}/documents/{idx} |
Edit a staged document (partial) | 200 {status:"updated"} |
PATCH /api/v1/ingest/sessions/{id}/documents/{idx} |
Accept / reject one staged doc | 200 {status:"updated"} |
PATCH /api/v1/ingest/sessions/{id}/documents/{idx}/resolve |
Resolve a collision (keep / overwrite / merge) | 200 {status:"resolved"} |
POST /api/v1/ingest/sessions/{id}/documents/accept-all |
Accept all (optionally one type) | 200 {accepted:N} |
POST /api/v1/ingest/sessions/{id}/files/{file_hash}/retry |
Re-process one failed file | 202 UploadResponse |
POST /api/v1/ingest/sessions/{id}/retry-failed |
Re-process all failed files | 202 UploadResponse |
POST /api/v1/ingest/sessions/{id}/commit |
Write accepted docs to ES + tracker | 200 CommitResponse |
POST /api/v1/ingest/sessions/{id}/recommit-tracking |
Durability recovery: retry failed tracker writes | 200 RecommitTrackingResponse |
Upload / scan¶
POST /upload is multipart/form-data: one or more files plus optional form fields
knowledge_type_hint, project_hint, equipment_hint, and force (re-import a file
whose hash was already committed). POST /scan takes a JSON ScanRequest:
{
"folder_path": "incoming/2024-06",
"recursive": false,
"knowledge_type_hint": "alarm",
"project_hint": "PDX",
"equipment_hint": "Aligner",
"force": false
}
folder_path is resolved under ingest.scan_root; a path escaping that root returns
400 (see Security). Both return an
UploadResponse:
{
"session_id": "8b1f…",
"files": [
{ "file_name": "alarms.pptx", "file_hash": "f3a…", "file_type": "pptx",
"file_size": 184320, "status": "processing", "message": "",
"chunks_total": null, "chunks_done": null, "skipped_chunks": [] }
]
}
Polling a session¶
GET /sessions/{id} returns a SessionResponse. Poll until status is
ready_for_review (terminal values: committed, failed):
{
"session_id": "8b1f…",
"status": "ready_for_review",
"message": "",
"files_total": 1,
"files_processed": 1,
"files": [
{ "file_name": "alarms.pptx", "file_hash": "f3a…", "file_type": "pptx",
"status": "done", "chunks_total": 6, "chunks_done": 6,
"skipped_chunks": [
{ "source_file": "alarms.pptx", "page_range": "1", "reason": "non_content",
"hint": "封面页,无知识内容" }
] }
],
"documents": [
{ "index": 0, "knowledge_type": "alarm", "project": "PDX", "equipment": "Aligner",
"title": "真空泄漏报警", "error_codes": ["E-1234"],
"content": "……", "resolution": "……", "notes": "",
"source_file": "alarms.pptx", "source_pages": ["12"],
"raw_text_excerpt": "……", "confidence": 0.82, "warnings": [], "accepted": true,
"collision": null, "collision_action": null, "related": [],
"dup_group_id": null, "dup_primary": true }
]
}
Each staged document may also carry enrichment fields added after segmentation (see Conflicts & cross-references below):
collision— set to anExistingDocSnapshotwhen committing this doc would overwrite an existing KB doc with the same identity; otherwisenull.collision_action(null|keep|overwrite|merge) is the reviewer's decision — whilecollisionis set andcollision_actionisnull, the doc is blocked from commit.related[]— related committed docs (RelatedDoc:doc_id,knowledge_type,title,equipment,error_codes,match_reason∈ {error_code,equipment,similar},snippet).dup_group_id/dup_primary— near-duplicate grouping within the batch; variants share adup_group_idand only thedup_primaryone defaults toaccepted.
ImportStatus values: pending · extracting · ready_for_review · committed ·
failed. FileStatus values: processing · skipped_duplicate · unsupported ·
failed · done. During segmentation chunks_done tracks completed chunks, so
a file reads chunks_done == chunks_total only once analysis is finished; the brief
dedup/assembly tail is reported as a Finalizing… message.
A file with status: skipped_duplicate additionally carries a duplicate_info
object describing what the KB already holds for that content, so the UI can explain
the skip:
{ "file_name": "alarms.pptx", "file_hash": "f3a…", "file_type": "pptx",
"status": "skipped_duplicate", "message": "Already imported on 2024-06-12T…",
"duplicate_info": {
"imported_at": "2024-06-12T08:31:00+00:00",
"original_file_name": "alarms-v1.pptx",
"doc_count": 14,
"documents": [
{ "knowledge_type": "alarm", "title": "真空泄漏报警", "error_codes": ["E-1234"] }
] } }
documents is capped at 50 entries (doc_count carries the true total so the UI can
render "+N more"); original_file_name may differ from the just-uploaded name when
the same bytes were imported under a different filename.
410 vs 404 on a session
A session id that expired (swept by the TTL evictor) returns 410 Gone — prompt
the user to re-upload. An id that never existed returns 404. TTLs are
ingest.session_ttl_minutes / session_hard_ttl_minutes
(see Configuration).
Editing & committing¶
PUT …/documents/{idx}— partial edit. Send only the fields you change (any ofproject,equipment,title,error_codes, the type-specific fields,notes,accepted).400ifidxis out of range.PATCH …/documents/{idx}—{ "accepted": true|false }.PATCH …/documents/{idx}/resolve— resolve a collision (see below).POST …/documents/accept-all— body optional{ "knowledge_type": "alarm" }to accept only one type; returns{ "accepted": N }.POST …/commit— indexes every accepted doc and records the file in the tracker.
Conflicts & cross-references¶
After segmentation, each staged doc is checked against the live KB. A doc whose
content-addressed doc_id already exists would overwrite that committed doc on
commit, so it is flagged (collision set) and blocked from commit until resolved
— a plain commit can never silently overwrite. Resolve with:
// PATCH …/documents/{idx}/resolve
{ "action": "merge",
"merged_fields": { "content": "…merged…", "resolution": "…" } }
action |
Effect on commit |
|---|---|
keep |
Skip this doc — the existing KB doc is preserved (counts as skipped). |
overwrite |
Index this doc as-is, replacing the existing one. |
merge |
Apply merged_fields (a partial edit — content fields only; identity fields are excluded so the doc_id stays stable), then index. |
GET …/sessions/{id}/summary returns a CommitSummary of what commit will do — use
it to drive a review banner and gate the Commit button:
{ "new": 3, "overwrite": 1, "keep": 1, "unresolved_conflicts": 0,
"dup_groups": 1, "missing_required": 0, "skipped_duplicate_files": 2, "rejected": 1 }
An accepted doc with an unresolved collision (unresolved_conflicts > 0) is reported
as a commit error and not indexed.
CommitResponse:
vectors_skipped counts docs indexed without vectors because the embedding service was
down (still BM25-searchable). tracking_failed > 0 means docs are in ES but their tracker
rows did not update — they would be lost on the next startup reseed, so call
recommit-tracking to recover:
// POST …/recommit-tracking → RecommitTrackingResponse
{ "recovered": 2, "still_failed": 0, "errors": [] }
Retry¶
POST …/files/{file_hash}/retry and POST …/retry-failed re-run extraction+segmentation
for failed files. Body is optional { "force_ocr": true }, which turns OCR on for that
retry even when ingest.ocr_enabled is off — useful to recover a scanned/image-only PDF
without a server config change. Both return 202 + the updated UploadResponse; a bad
session/file id returns 404.
Worked end-to-end example¶
# 1. Upload a PDF with hints
SID=$(curl -s -X POST localhost:8000/api/v1/ingest/upload \
-F 'files=@alarms.pdf' -F 'knowledge_type_hint=alarm' -F 'project_hint=PDX' \
| jq -r .session_id)
# 2. Poll until ready_for_review
until [ "$(curl -s localhost:8000/api/v1/ingest/sessions/$SID | jq -r .status)" \
= "ready_for_review" ]; do sleep 1; done
# 3. (optional) Fix a misextracted field on doc #0
curl -s -X PUT localhost:8000/api/v1/ingest/sessions/$SID/documents/0 \
-H 'content-type: application/json' -d '{"equipment":"Aligner"}'
# 4. Accept everything and commit
curl -s -X POST localhost:8000/api/v1/ingest/sessions/$SID/documents/accept-all
curl -s -X POST localhost:8000/api/v1/ingest/sessions/$SID/commit | jq
# 5. The committed docs are now searchable
curl -s localhost:8000/api/v1/search -H 'content-type: application/json' \
-d '{"knowledge_type":"alarm","project":"PDX","keywords":["真空"],"mode":"auto"}' | jq
Taxonomy & admin¶
| Method & path | Purpose | Success |
|---|---|---|
GET /api/v1/facets |
Return the live taxonomy (Taxonomy model) |
200 |
POST /api/v1/admin/reload-taxonomy |
Reload config/taxonomy.yaml without a restart |
200 |
GET /api/v1/admin/search-feedback?limit=20 |
Aggregate 👍/👎 feedback for ranking tuning | 200 / 503 |
GET /api/v1/facets →
{
"version": "2026-05-19-r1",
"knowledge_types": ["alarm", "setup", "experience"],
"projects": ["Kinneret", "MEM", "MHK", "PDX", "Boston", "Sonora", "Yucatan", "所有项目"],
"equipment": ["Aligner", "Conveyor", "FTU", "Heater", "Loader", "Pump", "SensorModule", "Stage"]
}
The taxonomy backs both filter validation at index time and LLM extraction priming — see Data Model → Taxonomy.
Feedback¶
| Method & path | Purpose | Success |
|---|---|---|
POST /api/v1/search/feedback |
Record one 👍/👎 on a result | 202 {status:"recorded"} |
Observational only — it never alters results. Body (FeedbackRequest):
{
"doc_id": "kb_alarm_v1:9f2c…",
"helpful": false,
"query_text": "E-1234 aligner fault",
"knowledge_type": "alarm",
"project": "PDX",
"equipment": "Aligner",
"search_status": "loose_hit"
}
Recording is best-effort: a storage hiccup returns 503 rather than breaking the user's flow. See Observability → Search feedback for the stored schema and the admin aggregate.
Operational & meta¶
| Method & path | Purpose |
|---|---|
GET /healthz |
Liveness — process up; no dependency probe. Always 200 {status:"ok"} |
GET /readyz |
Readiness — pings ES; 200 when ES reachable, 503 (degraded) otherwise |
GET /readyz?deep=true |
Also round-trips the embedding service; reports embedding: ok\|down\|disabled\|configured |
GET /metrics |
Prometheus metrics (when observability.metrics_enabled) |
GET /docs, /redoc, /openapi.json |
Interactive API docs |
GET / |
The bundled single-page frontend (Knowledge Base Search.html) |
GET /readyz?deep=true body:
See Observability → Health endpoints for how to wire these into an orchestrator.