feat(server): expose typed semantic transition review
This commit is contained in:
@@ -72,6 +72,18 @@ pub(crate) fn router() -> Router<AppState> {
|
||||
"/api/v1/projects/{id}/transitions/apply",
|
||||
post(commit_transition),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/projects/{id}/semantic-transitions/query",
|
||||
post(query_semantic_transitions),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/projects/{id}/semantic-transitions/preview",
|
||||
post(preview_semantic_transition),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/projects/{id}/semantic-transitions/apply",
|
||||
post(commit_semantic_transition),
|
||||
)
|
||||
.fallback(|| async {
|
||||
ApiError::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -1089,6 +1101,164 @@ struct TransitionApplyRequest {
|
||||
plan: oa_core::transitions::TransitionPlan,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SemanticQueryRequest {
|
||||
traces: Vec<Id>,
|
||||
#[serde(default)]
|
||||
references: BTreeMap<Id, Id>,
|
||||
revision: Option<u64>,
|
||||
}
|
||||
|
||||
fn semantic_rejected(error: impl std::fmt::Display) -> ApiError {
|
||||
ApiError::new(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"semantic_transition_rejected",
|
||||
error.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn query_semantic_transitions(
|
||||
State(state): State<AppState>,
|
||||
id: ProjectPath,
|
||||
headers: HeaderMap,
|
||||
body: Body,
|
||||
) -> ApiResult<Response> {
|
||||
let id = path(id)?;
|
||||
media(&headers, "application/json")?;
|
||||
let body = bytes(body)?;
|
||||
blocking(state, move |store| {
|
||||
let request: SemanticQueryRequest = json_body(&body)?;
|
||||
if request.traces.is_empty() || request.traces.len() > 100 || request.references.len() > 5_000 {
|
||||
return Err(ApiError::bad_request("Semantic queries require 1–100 trace IDs and at most 5,000 reference mappings."));
|
||||
}
|
||||
let selected: BTreeSet<_> = request.traces.iter().copied().collect();
|
||||
if selected.len() != request.traces.len() {
|
||||
return Err(ApiError::bad_request("Semantic query trace IDs must be unique."));
|
||||
}
|
||||
let current = store.get(id)?;
|
||||
check_read_revision(¤t, request.revision)?;
|
||||
let mut items = Vec::new();
|
||||
for trace_id in request.traces {
|
||||
let trace = current.model.traces.get(&trace_id).ok_or_else(|| {
|
||||
ApiError::new(StatusCode::NOT_FOUND, "trace_not_found", "Selected trace does not exist.")
|
||||
})?;
|
||||
let facets = oa_core::semantic::semantic_facets(¤t.model, trace, &request.references)
|
||||
.map_err(semantic_rejected)?;
|
||||
let drift = oa_core::semantic::trace_semantic_drift(¤t.model, trace, &request.references)
|
||||
.map_err(semantic_rejected)?;
|
||||
let linked_impacts = oa_core::semantic::linked_semantic_impacts(¤t.model, trace, &request.references)
|
||||
.map_err(semantic_rejected)?;
|
||||
items.push(json!({"trace":trace.id,"oa":trace.oa,"sa":trace.sa,"facets":facets,"drift":drift,"linked_impacts":linked_impacts}));
|
||||
}
|
||||
Ok(response(¤t, StatusCode::OK, json!({"revision":current.revision,"items":items})))
|
||||
}).await
|
||||
}
|
||||
|
||||
fn check_semantic_request(request: &oa_core::semantic::SemanticTransitionRequest) -> ApiResult<()> {
|
||||
let transition = &request.transition;
|
||||
let decisions = request.reviews.iter().try_fold(0usize, |count, review| {
|
||||
count
|
||||
.checked_add(review.resolutions.len())?
|
||||
.checked_add(review.reset.len())
|
||||
});
|
||||
if transition.sources.len() > 100
|
||||
|| transition.mappings.len() > 200
|
||||
|| transition.resolutions.len() > 400
|
||||
|| request.traces.len() > 100
|
||||
|| request.references.len() > 5_000
|
||||
|| request.reviews.len() > 100
|
||||
|| decisions.is_none_or(|count| count > 5_000)
|
||||
|| request.candidates.len() > 200
|
||||
|| request.accepted_impacts.len() > 200
|
||||
{
|
||||
return Err(ApiError::bad_request(
|
||||
"Semantic transitions allow at most 100 sources/traces/reviews, 200 mappings/candidates/accepted impacts, 400 prose resolutions and 5,000 reference mappings or combined facet resolutions/resets.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn preview_semantic_transition(
|
||||
State(state): State<AppState>,
|
||||
id: ProjectPath,
|
||||
headers: HeaderMap,
|
||||
body: Body,
|
||||
) -> ApiResult<Response> {
|
||||
let id = path(id)?;
|
||||
let expected = expected(&headers)?;
|
||||
media(&headers, "application/json")?;
|
||||
let body = bytes(body)?;
|
||||
blocking(state, move |store| {
|
||||
let request: oa_core::semantic::SemanticTransitionRequest = json_body(&body)?;
|
||||
check_semantic_request(&request)?;
|
||||
let current = store.checked(id, &expected)?;
|
||||
let plan = oa_core::semantic::plan_semantic_transition(¤t.model, request)
|
||||
.map_err(semantic_rejected)?;
|
||||
if !plan.commands.is_empty() {
|
||||
check_commands(&plan.commands)?;
|
||||
}
|
||||
if plan.conflicts.is_empty() {
|
||||
let candidate = oa_core::semantic::prepare_semantic_transition(¤t.model, &plan)
|
||||
.map_err(semantic_rejected)?;
|
||||
PreparedProject::from_command(¤t, candidate)?;
|
||||
}
|
||||
Ok(response(
|
||||
¤t,
|
||||
StatusCode::OK,
|
||||
json!({"base_revision":current.revision,"plan":plan}),
|
||||
))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SemanticApplyRequest {
|
||||
base_revision: u64,
|
||||
plan: oa_core::semantic::SemanticTransitionPlan,
|
||||
}
|
||||
|
||||
async fn commit_semantic_transition(
|
||||
State(state): State<AppState>,
|
||||
id: ProjectPath,
|
||||
headers: HeaderMap,
|
||||
body: Body,
|
||||
) -> ApiResult<Response> {
|
||||
let id = path(id)?;
|
||||
let expected = expected(&headers)?;
|
||||
media(&headers, "application/json")?;
|
||||
let body = bytes(body)?;
|
||||
blocking(state, move |store| {
|
||||
let request: SemanticApplyRequest = json_body(&body)?;
|
||||
let current = store.checked(id, &expected)?;
|
||||
if current.revision != request.base_revision {
|
||||
let mut error = ApiError::new(
|
||||
StatusCode::PRECONDITION_FAILED,
|
||||
"plan_revision_conflict",
|
||||
"Semantic preview revision changed. Create a new preview.",
|
||||
);
|
||||
error.current_revision = Some(current.revision);
|
||||
return Err(error);
|
||||
}
|
||||
check_semantic_request(&request.plan.request)?;
|
||||
if !request.plan.commands.is_empty() {
|
||||
check_commands(&request.plan.commands)?;
|
||||
}
|
||||
let candidate =
|
||||
oa_core::semantic::prepare_semantic_transition(¤t.model, &request.plan)
|
||||
.map_err(semantic_rejected)?;
|
||||
let prepared = PreparedProject::from_command(¤t, candidate)?;
|
||||
let (stored, changed) = store.replace(id, &expected, prepared)?;
|
||||
Ok(response(
|
||||
&stored,
|
||||
StatusCode::OK,
|
||||
json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model)}),
|
||||
))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn commit_transition(
|
||||
State(state): State<AppState>,
|
||||
id: ProjectPath,
|
||||
|
||||
Reference in New Issue
Block a user