feat(admin): runtime-editable crawler & analysis config in the dashboard
Move crawler and analysis configuration out of boot-only env vars and into
a DB-backed, admin-editable surface applied live without a restart.
- New `app_settings` table (migration 0026) holds one JSONB row per group;
env vars seed it on first boot, then the DB is the source of truth.
- `settings.rs` adds serializable Crawler/Analysis DTOs with env-base +
DB-overlay conversion and field-level validation; env-only/secret fields
(browser, proxy, TOR, vision API key, PHPSESSID) are never persisted.
- `GET/PUT /api/v1/admin/settings/{crawler,analysis}` (RequireAdmin,
cookie-only, CSRF-guarded): GET returns the editable DTO + a read-only
env-managed view + prompt defaults; PUT validates (422 + per-field
details), persists + audits in one tx, then gracefully respawns the
affected daemon via a Supervisor.
- AppState swaps the crawler control/resync handles and analysis enable
gate behind shared runtime cells so reloads take effect with no restart;
a boot-time spawn failure is logged rather than aborting startup.
- Make the three vision prompts and sampling temperature configurable
(defaults preserved); cookie_domain is admin-editable too.
- Frontend: tabbed /admin/settings page with grouped fieldsets, prompt
reset-to-default, env-managed read-only panel, save-&-apply confirm, and
per-field validation; typed client + ApiError.details.
- Bump version 0.79.1 -> 0.80.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -90,6 +90,27 @@ impl ResponseFormat {
|
||||
_ => ResponseFormat::JsonSchema,
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical wire string, the inverse of [`Self::from_str`] for the
|
||||
/// three modes the API exposes.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
ResponseFormat::JsonSchema => "json_schema",
|
||||
ResponseFormat::JsonObject => "json_object",
|
||||
ResponseFormat::None => "none",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a wire value, rejecting unknown modes (stricter than
|
||||
/// [`Self::from_str`], which the env loader uses to stay lenient).
|
||||
pub fn parse_strict(s: &str) -> Option<ResponseFormat> {
|
||||
match s.trim().to_lowercase().as_str() {
|
||||
"json_schema" => Some(ResponseFormat::JsonSchema),
|
||||
"json_object" => Some(ResponseFormat::JsonObject),
|
||||
"none" => Some(ResponseFormat::None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AI content-analysis worker configuration: the enable gate, the local
|
||||
@@ -146,6 +167,21 @@ pub struct AnalysisConfig {
|
||||
/// the repetition loops that otherwise run small models into the token
|
||||
/// ceiling. `0` omits the field.
|
||||
pub frequency_penalty: f64,
|
||||
/// Sampling `temperature` sent with each request (`ANALYSIS_TEMPERATURE`).
|
||||
/// Defaults to `0.0` (deterministic), which is the most reliable for
|
||||
/// structured JSON output; some models behave better with a small
|
||||
/// positive value.
|
||||
pub temperature: f64,
|
||||
/// System prompt for the single-call (normal-aspect) analysis path
|
||||
/// (`ANALYSIS_SYSTEM_PROMPT`). Defaults to
|
||||
/// [`crate::analysis::prompt::SYSTEM_PROMPT_DEFAULT`].
|
||||
pub system_prompt: String,
|
||||
/// Pass-A OCR-only prompt for tall-page slices (`ANALYSIS_OCR_PROMPT`).
|
||||
/// Defaults to [`crate::analysis::prompt::OCR_PROMPT_DEFAULT`].
|
||||
pub ocr_prompt: String,
|
||||
/// Pass-B grounding prompt — tags/scene/safety (`ANALYSIS_GROUNDING_PROMPT`).
|
||||
/// Defaults to [`crate::analysis::prompt::GROUNDING_PROMPT_DEFAULT`].
|
||||
pub grounding_prompt: String,
|
||||
}
|
||||
|
||||
impl Default for AnalysisConfig {
|
||||
@@ -170,6 +206,10 @@ impl Default for AnalysisConfig {
|
||||
max_image_bytes: 8 * 1024 * 1024,
|
||||
response_format: ResponseFormat::JsonSchema,
|
||||
frequency_penalty: 0.3,
|
||||
temperature: 0.0,
|
||||
system_prompt: crate::analysis::prompt::SYSTEM_PROMPT_DEFAULT.to_string(),
|
||||
ocr_prompt: crate::analysis::prompt::OCR_PROMPT_DEFAULT.to_string(),
|
||||
grounding_prompt: crate::analysis::prompt::GROUNDING_PROMPT_DEFAULT.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,10 +246,23 @@ impl AnalysisConfig {
|
||||
.map(|s| ResponseFormat::from_str(&s))
|
||||
.unwrap_or(d.response_format),
|
||||
frequency_penalty: env_f64("ANALYSIS_FREQUENCY_PENALTY", d.frequency_penalty),
|
||||
temperature: env_f64("ANALYSIS_TEMPERATURE", d.temperature),
|
||||
system_prompt: env_prompt("ANALYSIS_SYSTEM_PROMPT", d.system_prompt),
|
||||
ocr_prompt: env_prompt("ANALYSIS_OCR_PROMPT", d.ocr_prompt),
|
||||
grounding_prompt: env_prompt("ANALYSIS_GROUNDING_PROMPT", d.grounding_prompt),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a prompt override from env, falling back to the default when unset
|
||||
/// or blank (so `ANALYSIS_SYSTEM_PROMPT=` doesn't wipe the prompt).
|
||||
fn env_prompt(name: &str, default: String) -> String {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
pub database_url: String,
|
||||
|
||||
Reference in New Issue
Block a user