mirror of
https://github.com/thegeneralist01/archivr
synced 2026-10-09 12:55:00 +02:00
feat(server): add GET/POST /api/archives/:id/entries/:uid/summary
GET is read-only and gated exactly like entry detail, so a guest can read a summary only for an entry whose content they could already read. POST requires ROLE_USER, matching capture / tags / patch / rearchive; no auth roles change. Both the provider config and the content extraction resolve on the request thread, before spawn_blocking. That is what lets a missing env var come back as a synchronous 400 naming the exact variable, and an unsummarizable artifact (video, audio) as a 400 saying so, rather than becoming a background job the caller must poll only to learn about a config typo. The pending row is claimed before spawning so the 202 can name a summary_uid the client can poll immediately. summarize_entry owns the pending → running → completed/failed transitions for that same row — the cache key is identical, so both upserts resolve to one row — leaving the handler to catch only the case where it fails before recording anything. When !force and an identical cache key already completed, the existing row comes back as a 200 with no new work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
fd3f6903e2
commit
19f3ea41c1
1 changed files with 147 additions and 1 deletions
|
|
@ -29,7 +29,7 @@ use std::{
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
use archivr_core::{archive, capture, database, downloader};
|
use archivr_core::{archive, capture, database, downloader, summarizer};
|
||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path, Query, Request, State},
|
extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path, Query, Request, State},
|
||||||
|
|
@ -268,6 +268,10 @@ pub fn app_with_state(state: AppState) -> Router {
|
||||||
"/api/archives/:archive_id/entries/:entry_uid/rearchive",
|
"/api/archives/:archive_id/entries/:entry_uid/rearchive",
|
||||||
post(rearchive_handler),
|
post(rearchive_handler),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/archives/:archive_id/entries/:entry_uid/summary",
|
||||||
|
get(entry_summary_handler).post(request_entry_summary_handler),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/archives/:archive_id/entries/:entry_uid/favicon",
|
"/api/archives/:archive_id/entries/:entry_uid/favicon",
|
||||||
get(serve_entry_favicon),
|
get(serve_entry_favicon),
|
||||||
|
|
@ -513,6 +517,148 @@ async fn entry_detail(
|
||||||
Ok(Json(detail))
|
Ok(Json(detail))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct SummaryRequestBody {
|
||||||
|
provider: String,
|
||||||
|
#[serde(default)]
|
||||||
|
force: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/archives/:id/entries/:uid/summary`
|
||||||
|
///
|
||||||
|
/// Read-only, gated exactly like entry detail: a guest may read a summary only
|
||||||
|
/// for an entry whose content they could already read. Returns
|
||||||
|
/// `{ entry_uid, summary }` with a null summary when none has been requested,
|
||||||
|
/// which is also what the frontend polls while a job is running.
|
||||||
|
async fn entry_summary_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Path((archive_id, entry_uid)): Path<(String, String)>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let mounted = mounted_archive(&state, &archive_id)?;
|
||||||
|
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||||
|
if matches!(auth_user, AuthUser::Guest)
|
||||||
|
&& !database::is_entry_publicly_accessible(&conn, &entry_uid)?
|
||||||
|
{
|
||||||
|
return Err(ApiError::unauthorized("login required"));
|
||||||
|
}
|
||||||
|
let entry_id = database::entry_id_for_uid(&conn, &entry_uid)?
|
||||||
|
.ok_or(ApiError::not_found("entry not found"))?;
|
||||||
|
let summary = database::latest_entry_summary(&conn, entry_id)?;
|
||||||
|
Ok(Json(
|
||||||
|
serde_json::json!({ "entry_uid": entry_uid, "summary": summary }),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/archives/:id/entries/:uid/summary`
|
||||||
|
///
|
||||||
|
/// Manual-only summary generation. Body: `{ "provider": "...", "force": false }`.
|
||||||
|
///
|
||||||
|
/// Provider configuration and content extraction are both resolved *before*
|
||||||
|
/// spawning, so a missing env var or an unsummarizable artifact comes back as a
|
||||||
|
/// synchronous 400 naming the exact problem rather than as a background job the
|
||||||
|
/// caller has to poll only to learn about a config typo.
|
||||||
|
///
|
||||||
|
/// Returns 200 with the existing row when an identical cache key already
|
||||||
|
/// completed and `force` is false; otherwise 202 with a pending row.
|
||||||
|
async fn request_entry_summary_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Path((archive_id, entry_uid)): Path<(String, String)>,
|
||||||
|
Json(body): Json<SummaryRequestBody>,
|
||||||
|
) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
|
||||||
|
auth_user.require_role(ROLE_USER)?;
|
||||||
|
let mounted = mounted_archive(&state, &archive_id)?;
|
||||||
|
let archive_paths =
|
||||||
|
archive::read_archive_paths(&mounted.archive_path).map_err(ApiError::from)?;
|
||||||
|
|
||||||
|
// 1. Provider config from the environment. The error text carries the exact
|
||||||
|
// variable name, which is the whole point of returning it as a 400.
|
||||||
|
let provider_cfg = summarizer::provider_from_env(&body.provider)
|
||||||
|
.map_err(|e| ApiError::bad_request(&format!("{e:#}")))?;
|
||||||
|
let provider = summarizer::provider_from_config(provider_cfg);
|
||||||
|
|
||||||
|
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||||
|
let entry_id = database::entry_id_for_uid(&conn, &entry_uid)?
|
||||||
|
.ok_or(ApiError::not_found("entry not found"))?;
|
||||||
|
|
||||||
|
// 2. Extract the same content the summarizer will feed the model, so the
|
||||||
|
// digest below is the identical cache key summarize_entry will compute.
|
||||||
|
let input = summarizer::build_summary_input(&archive_paths, &entry_uid)
|
||||||
|
.map_err(|e| ApiError::bad_request(&format!("{e:#}")))?;
|
||||||
|
|
||||||
|
// 3. Cache hit: identical entry + provider + model + prompt + input.
|
||||||
|
if !body.force {
|
||||||
|
if let Some(existing) = database::find_entry_summary(
|
||||||
|
&conn,
|
||||||
|
entry_id,
|
||||||
|
provider.kind(),
|
||||||
|
provider.model(),
|
||||||
|
summarizer::PROMPT_VERSION,
|
||||||
|
&input.input_sha256,
|
||||||
|
)? {
|
||||||
|
if existing.status == "completed" {
|
||||||
|
return Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
serde_json::to_value(&existing)
|
||||||
|
.map(Json)
|
||||||
|
.map_err(|e| ApiError::internal(&e.to_string()))?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Claim the row up front so the 202 can name it and the client can poll
|
||||||
|
// immediately, before the worker thread has done anything.
|
||||||
|
let summary_uid = database::upsert_pending_entry_summary(
|
||||||
|
&conn,
|
||||||
|
entry_id,
|
||||||
|
provider.kind(),
|
||||||
|
provider.model(),
|
||||||
|
summarizer::PROMPT_VERSION,
|
||||||
|
&input.input_sha256,
|
||||||
|
)?;
|
||||||
|
drop(conn);
|
||||||
|
|
||||||
|
let archive_path = mounted.archive_path.clone();
|
||||||
|
let entry_uid_bg = entry_uid.clone();
|
||||||
|
let summary_uid_bg = summary_uid.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
// summarize_entry owns the pending → running → completed/failed
|
||||||
|
// transitions for this same row (the cache key is identical, so the
|
||||||
|
// upsert above and the one inside it resolve to one row). We only have
|
||||||
|
// to catch the case where it fails before it can record anything.
|
||||||
|
if let Err(e) =
|
||||||
|
summarizer::summarize_entry(&archive_paths, &entry_uid_bg, provider.as_ref(), summarizer::PROMPT_VERSION)
|
||||||
|
{
|
||||||
|
eprintln!("warn: summary {summary_uid_bg}: {e:#}");
|
||||||
|
if let Ok(conn) = database::open_or_initialize(&archive_path) {
|
||||||
|
if let Ok(Some(row)) = database::get_entry_summary_by_uid(&conn, &summary_uid_bg) {
|
||||||
|
if row.status != "failed" {
|
||||||
|
database::update_entry_summary_status(
|
||||||
|
&conn,
|
||||||
|
&summary_uid_bg,
|
||||||
|
"failed",
|
||||||
|
None,
|
||||||
|
Some(&format!("{e:#}")),
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
StatusCode::ACCEPTED,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"summary_uid": summary_uid,
|
||||||
|
"status": "pending",
|
||||||
|
"entry_uid": entry_uid,
|
||||||
|
})),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_runs(
|
async fn list_runs(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue