Skip to content

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/json unless noted (file upload is multipart/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 when KB_EMBEDDING__API_KEY is unset (search still returns 200). See Configuration.

POST /api/v1/search

Structured hybrid search (src/kb/api/search.pysrc/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 an ExistingDocSnapshot when committing this doc would overwrite an existing KB doc with the same identity; otherwise null. collision_action (null | keep | overwrite | merge) is the reviewer's decision — while collision is set and collision_action is null, 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 a dup_group_id and only the dup_primary one defaults to accepted.

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 of project, equipment, title, error_codes, the type-specific fields, notes, accepted). 400 if idx is 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:

{
  "committed": 5,
  "skipped": 1,
  "errors": [],
  "vectors_skipped": 0,
  "tracking_failed": 0
}

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:

{ "status": "ok", "es": "ok", "embedding": "ok", "llm": "configured" }

See Observability → Health endpoints for how to wire these into an orchestrator.