Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 199 additions & 11 deletions apps/elf-api/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ use elf_domain::{
ConsolidationReviewState,
},
english_gate,
knowledge::KnowledgePageKind,
writegate::WritePolicy,
};
use elf_service::{
Expand All @@ -50,17 +51,19 @@ use elf_service::{
DocType, DocsExcerptResponse, DocsExcerptsGetRequest, DocsGetRequest, DocsGetResponse,
DocsPutRequest, DocsPutResponse, DocsSearchL0Request, DocsSearchL0Response, Error,
EventMessage, GranteeKind, GraphQueryEntityRef, GraphQueryPredicateRef, GraphQueryRequest,
GraphQueryResponse, IngestionProfileSelector, ListRequest, ListResponse, NoteFetchRequest,
NoteFetchResponse, NoteProvenanceBundleResponse, NoteProvenanceGetRequest, PayloadLevel,
PublishNoteRequest, QueryPlan, RankingRequestOverride, RebuildReport, SearchDetailsRequest,
SearchDetailsResult, SearchExplainRequest, SearchExplainResponse, SearchIndexItem,
SearchRequest, SearchResponse, SearchSessionGetRequest, SearchTimelineGroup,
SearchTimelineRequest, SearchTrajectoryResponse, SearchTrajectorySummary, ShareScope,
SpaceGrantRevokeRequest, SpaceGrantRevokeResponse, SpaceGrantUpsertRequest,
SpaceGrantsListRequest, TextPositionSelector, TextQuoteSelector, TraceBundleGetRequest,
TraceBundleResponse, TraceGetRequest, TraceGetResponse, TraceRecentListRequest,
TraceRecentListResponse, TraceTrajectoryGetRequest, UnpublishNoteRequest, UpdateRequest,
UpdateResponse, search::TraceBundleMode,
GraphQueryResponse, IngestionProfileSelector, KnowledgePageGetRequest,
KnowledgePageLintRequest, KnowledgePageLintResponse, KnowledgePageRebuildRequest,
KnowledgePageRebuildResponse, KnowledgePageResponse, KnowledgePagesListRequest,
KnowledgePagesListResponse, ListRequest, ListResponse, NoteFetchRequest, NoteFetchResponse,
NoteProvenanceBundleResponse, NoteProvenanceGetRequest, PayloadLevel, PublishNoteRequest,
QueryPlan, RankingRequestOverride, RebuildReport, SearchDetailsRequest, SearchDetailsResult,
SearchExplainRequest, SearchExplainResponse, SearchIndexItem, SearchRequest, SearchResponse,
SearchSessionGetRequest, SearchTimelineGroup, SearchTimelineRequest, SearchTrajectoryResponse,
SearchTrajectorySummary, ShareScope, SpaceGrantRevokeRequest, SpaceGrantRevokeResponse,
SpaceGrantUpsertRequest, SpaceGrantsListRequest, TextPositionSelector, TextQuoteSelector,
TraceBundleGetRequest, TraceBundleResponse, TraceGetRequest, TraceGetResponse,
TraceRecentListRequest, TraceRecentListResponse, TraceTrajectoryGetRequest,
UnpublishNoteRequest, UpdateRequest, UpdateResponse, search::TraceBundleMode,
};

/// JSON OpenAPI contract route.
Expand Down Expand Up @@ -133,6 +136,10 @@ const VIEWER_HTML: &str = include_str!("../static/viewer.html");
consolidation_proposals_list,
consolidation_proposal_get,
consolidation_proposal_review,
knowledge_page_rebuild,
knowledge_pages_list,
knowledge_page_get,
knowledge_page_lint,
rebuild_qdrant,
searches_raw,
trace_recent_list,
Expand All @@ -159,6 +166,7 @@ const VIEWER_HTML: &str = include_str!("../static/viewer.html");
(name = "search", description = "Progressive search sessions and raw search diagnostics."),
(name = "graph", description = "Graph query and predicate administration."),
(name = "consolidation", description = "Reviewable derived consolidation proposals."),
(name = "knowledge", description = "Derived knowledge page rebuild and lint readback."),
(name = "admin", description = "Local admin and operator inspection routes."),
)
)]
Expand Down Expand Up @@ -362,6 +370,29 @@ struct ConsolidationProposalReviewBody {
review_comment: Option<String>,
}

#[derive(Clone, Debug, Deserialize)]
struct KnowledgePageRebuildBody {
page_kind: KnowledgePageKind,
page_key: String,
title: Option<String>,
#[serde(default)]
note_ids: Vec<Uuid>,
#[serde(default)]
event_ids: Vec<Uuid>,
#[serde(default)]
relation_ids: Vec<Uuid>,
#[serde(default)]
proposal_ids: Vec<Uuid>,
#[serde(default = "empty_json_object")]
provider_metadata: Value,
}

#[derive(Clone, Debug, Deserialize)]
struct KnowledgePagesListQuery {
page_kind: Option<KnowledgePageKind>,
limit: Option<u32>,
}

#[derive(Clone, Debug, Serialize, ToSchema)]
struct AdminIngestionProfileDefaultResponseV2 {
profile_id: String,
Expand Down Expand Up @@ -645,6 +676,10 @@ pub fn admin_router(state: AppState) -> Router {
"/v2/admin/consolidation/proposals/{proposal_id}/review",
routing::post(consolidation_proposal_review),
)
.route("/v2/admin/knowledge/pages", routing::get(knowledge_pages_list))
.route("/v2/admin/knowledge/pages/rebuild", routing::post(knowledge_page_rebuild))
.route("/v2/admin/knowledge/pages/{page_id}", routing::get(knowledge_page_get))
.route("/v2/admin/knowledge/pages/{page_id}/lint", routing::post(knowledge_page_lint))
.route("/v2/admin/qdrant/rebuild", routing::post(rebuild_qdrant))
.route("/v2/admin/searches/raw", routing::post(searches_raw))
.route("/v2/admin/traces/recent", routing::get(trace_recent_list))
Expand Down Expand Up @@ -2671,6 +2706,159 @@ async fn consolidation_proposal_review(
Ok(Json(response))
}

#[utoipa::path(
post,
path = "/v2/admin/knowledge/pages/rebuild",
tag = "knowledge",
request_body = Value,
responses(
(status = 200, description = "Knowledge page was rebuilt.", body = Value),
(status = 400, description = "Invalid request.", body = ErrorBody),
(status = 401, description = "Authentication required.", body = ErrorBody),
(status = 403, description = "Admin access required.", body = ErrorBody),
(status = 500, description = "Internal error.", body = ErrorBody),
)
)]
async fn knowledge_page_rebuild(
State(state): State<AppState>,
headers: HeaderMap,
payload: Result<Json<KnowledgePageRebuildBody>, JsonRejection>,
) -> Result<Json<KnowledgePageRebuildResponse>, ApiError> {
let ctx = RequestContext::from_headers(&headers)?;
let Json(payload) = payload.map_err(|err| {
tracing::warn!(error = %err, "Invalid request payload.");

json_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", "Invalid request payload.", None)
})?;
let response = state
.service
.knowledge_page_rebuild(KnowledgePageRebuildRequest {
tenant_id: ctx.tenant_id,
project_id: ctx.project_id,
agent_id: ctx.agent_id,
page_kind: payload.page_kind,
page_key: payload.page_key,
title: payload.title,
note_ids: payload.note_ids,
event_ids: payload.event_ids,
relation_ids: payload.relation_ids,
proposal_ids: payload.proposal_ids,
provider_metadata: payload.provider_metadata,
})
.await?;

Ok(Json(response))
}

#[utoipa::path(
get,
path = "/v2/admin/knowledge/pages",
tag = "knowledge",
params(
("page_kind" = Option<String>, Query, description = "Optional page-kind filter."),
("limit" = Option<u32>, Query, description = "Maximum pages to return."),
),
responses(
(status = 200, description = "Knowledge pages.", body = Value),
(status = 400, description = "Invalid request.", body = ErrorBody),
(status = 401, description = "Authentication required.", body = ErrorBody),
(status = 403, description = "Admin access required.", body = ErrorBody),
(status = 500, description = "Internal error.", body = ErrorBody),
)
)]
async fn knowledge_pages_list(
State(state): State<AppState>,
headers: HeaderMap,
query: Result<Query<KnowledgePagesListQuery>, QueryRejection>,
) -> Result<Json<KnowledgePagesListResponse>, ApiError> {
let ctx = RequestContext::from_headers(&headers)?;
let Query(query) = query.map_err(|err| {
tracing::warn!(error = %err, "Invalid query parameters.");

json_error(
StatusCode::BAD_REQUEST,
"INVALID_REQUEST",
"Invalid query parameters.".to_string(),
None,
)
})?;
let response = state
.service
.knowledge_pages_list(KnowledgePagesListRequest {
tenant_id: ctx.tenant_id,
project_id: ctx.project_id,
page_kind: query.page_kind,
limit: query.limit,
})
.await?;

Ok(Json(response))
}

#[utoipa::path(
get,
path = "/v2/admin/knowledge/pages/{page_id}",
tag = "knowledge",
params(("page_id" = Uuid, Path, description = "Knowledge page ID.")),
responses(
(status = 200, description = "Knowledge page.", body = Value),
(status = 400, description = "Invalid request.", body = ErrorBody),
(status = 401, description = "Authentication required.", body = ErrorBody),
(status = 403, description = "Admin access required.", body = ErrorBody),
(status = 404, description = "Knowledge page was not found.", body = ErrorBody),
(status = 500, description = "Internal error.", body = ErrorBody),
)
)]
async fn knowledge_page_get(
State(state): State<AppState>,
headers: HeaderMap,
Path(page_id): Path<Uuid>,
) -> Result<Json<KnowledgePageResponse>, ApiError> {
let ctx = RequestContext::from_headers(&headers)?;
let response = state
.service
.knowledge_page_get(KnowledgePageGetRequest {
tenant_id: ctx.tenant_id,
project_id: ctx.project_id,
page_id,
})
.await?;

Ok(Json(response))
}

#[utoipa::path(
post,
path = "/v2/admin/knowledge/pages/{page_id}/lint",
tag = "knowledge",
params(("page_id" = Uuid, Path, description = "Knowledge page ID.")),
responses(
(status = 200, description = "Knowledge page lint findings.", body = Value),
(status = 400, description = "Invalid request.", body = ErrorBody),
(status = 401, description = "Authentication required.", body = ErrorBody),
(status = 403, description = "Admin access required.", body = ErrorBody),
(status = 404, description = "Knowledge page was not found.", body = ErrorBody),
(status = 500, description = "Internal error.", body = ErrorBody),
)
)]
async fn knowledge_page_lint(
State(state): State<AppState>,
headers: HeaderMap,
Path(page_id): Path<Uuid>,
) -> Result<Json<KnowledgePageLintResponse>, ApiError> {
let ctx = RequestContext::from_headers(&headers)?;
let response = state
.service
.knowledge_page_lint(KnowledgePageLintRequest {
tenant_id: ctx.tenant_id,
project_id: ctx.project_id,
page_id,
})
.await?;

Ok(Json(response))
}

#[utoipa::path(
get,
path = "/v2/admin/events/ingestion-profiles",
Expand Down
5 changes: 5 additions & 0 deletions apps/elf-api/tests/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,10 @@ async fn openapi_json_route_serves_generated_contract() {
assert_openapi_method(&spec, "/v2/admin/consolidation/proposals", "get");
assert_openapi_method(&spec, "/v2/admin/consolidation/proposals/{proposal_id}", "get");
assert_openapi_method(&spec, "/v2/admin/consolidation/proposals/{proposal_id}/review", "post");
assert_openapi_method(&spec, "/v2/admin/knowledge/pages/rebuild", "post");
assert_openapi_method(&spec, "/v2/admin/knowledge/pages", "get");
assert_openapi_method(&spec, "/v2/admin/knowledge/pages/{page_id}", "get");
assert_openapi_method(&spec, "/v2/admin/knowledge/pages/{page_id}/lint", "post");
}

#[tokio::test]
Expand All @@ -875,6 +879,7 @@ async fn scalar_docs_route_serves_api_reference_html() {
assert!(html.contains("@scalar/api-reference"));
assert!(html.contains("/v2/admin/events/ingestion-profiles/default"));
assert!(html.contains("/v2/admin/consolidation/proposals"));
assert!(html.contains("/v2/admin/knowledge/pages"));
}

#[tokio::test]
Expand Down
2 changes: 2 additions & 0 deletions docs/spec/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ Question this index answers: "what must remain true?"
and storage invariants.
- `system_consolidation_proposals_v1.md`: Reviewable derived consolidation run and
proposal contract over immutable source evidence.
- `system_knowledge_pages_v1.md`: Derived project/entity/concept/issue/decision page
storage, rebuild, citation, and stale-source lint contract.
- `system_competitive_parity_gate_v1.md`: Docker-only adoption gate that decides
whether ELF meets or exceeds selected external memory-system baselines.
- `production_corpus_manifest_v1.md`: Sanitized/private coding-agent production
Expand Down
16 changes: 16 additions & 0 deletions docs/spec/system_elf_memory_service_v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -1006,6 +1006,22 @@ Behavior:
- They must not mutate authoritative source notes, docs, events, traces, graph facts,
or search traces.

Admin derived knowledge pages:
- POST /v2/admin/knowledge/pages/rebuild
- GET /v2/admin/knowledge/pages
- GET /v2/admin/knowledge/pages/{page_id}
- POST /v2/admin/knowledge/pages/{page_id}/lint

Behavior:
- These endpoints expose deterministic rebuild, list/detail readback, and stale-source
lint for derived knowledge pages.
- Page payloads must follow `elf.knowledge_page/v1`, preserve section citations, and
write normalized source refs for lint.
- Pages are derived and rebuildable; rebuilding or linting a page must not mutate
authoritative notes, event audits, graph facts, consolidation proposals, docs,
traces, or source pointers.
- The detailed contract is defined in `system_knowledge_pages_v1.md`.

POST /v2/admin/qdrant/rebuild

Behavior:
Expand Down
Loading