Files
teaching-feedback-assistant/server/src/config.rs

226 lines
7.1 KiB
Rust

use std::{env, net::IpAddr, path::PathBuf};
#[derive(Clone)]
pub enum SpeechProviderConfig {
Local { api_url: String },
Tencent(TencentSpeechConfig),
}
#[derive(Clone)]
pub struct TencentSpeechConfig {
pub app_id: u64,
pub secret_id: String,
pub secret_key: String,
pub engine_type: String,
}
#[derive(Clone)]
pub struct SpeechConfig {
pub provider: SpeechProviderConfig,
pub request_timeout_seconds: u64,
pub worker_concurrency: usize,
pub job_lease_seconds: i64,
}
#[derive(Clone)]
pub struct SummaryConfig {
pub api_url: String,
pub api_key: Option<String>,
pub model: String,
}
#[derive(Clone)]
pub struct WechatAuthConfig {
pub app_id: String,
pub app_secret: String,
pub code2session_url: String,
}
#[derive(Clone)]
pub struct AuthConfig {
pub wechat: Option<WechatAuthConfig>,
pub session_ttl_days: i64,
pub allow_development_user_header: bool,
}
#[derive(Clone)]
pub struct Config {
pub host: IpAddr,
pub port: u16,
pub database_url: Option<String>,
pub database_max_connections: u32,
pub cors_allowed_origin: Option<String>,
pub audio_storage_dir: PathBuf,
pub speech: Option<SpeechConfig>,
pub summary: Option<SummaryConfig>,
pub auth: AuthConfig,
}
impl Config {
pub fn from_env() -> Result<Self, String> {
let host = env::var("HOST")
.unwrap_or_else(|_| "127.0.0.1".to_owned())
.parse()
.map_err(|_| "HOST must be a valid IP address".to_owned())?;
let port = env::var("PORT")
.unwrap_or_else(|_| "8080".to_owned())
.parse()
.map_err(|_| "PORT must be a valid u16 value".to_owned())?;
let database_max_connections = env::var("DATABASE_MAX_CONNECTIONS")
.unwrap_or_else(|_| "5".to_owned())
.parse()
.map_err(|_| "DATABASE_MAX_CONNECTIONS must be a valid u32 value".to_owned())?;
let speech = speech_config()?;
let summary = summary_config()?;
let auth = auth_config()?;
Ok(Self {
host,
port,
database_url: env::var("DATABASE_URL")
.ok()
.filter(|value| !value.trim().is_empty()),
database_max_connections,
cors_allowed_origin: env::var("CORS_ALLOWED_ORIGIN")
.ok()
.filter(|value| !value.trim().is_empty()),
audio_storage_dir: env::var("AUDIO_STORAGE_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("./data/audio")),
speech,
summary,
auth,
})
}
}
fn auth_config() -> Result<AuthConfig, String> {
let app_id = optional_env("WECHAT_APP_ID");
let app_secret = optional_env("WECHAT_APP_SECRET");
let wechat = match (app_id, app_secret) {
(Some(app_id), Some(app_secret)) => Some(WechatAuthConfig {
app_id,
app_secret,
code2session_url: optional_env("WECHAT_CODE2SESSION_URL")
.unwrap_or_else(|| "https://api.weixin.qq.com/sns/jscode2session".to_owned()),
}),
(None, None) => None,
_ => {
return Err(
"WECHAT_APP_ID and WECHAT_APP_SECRET must be configured together".to_owned(),
);
}
};
let session_ttl_days = parse_env("AUTH_SESSION_TTL_DAYS", 30_i64)?;
if !(1..=365).contains(&session_ttl_days) {
return Err("AUTH_SESSION_TTL_DAYS must be between 1 and 365".to_owned());
}
let allow_development_user_header = parse_bool_env("ALLOW_DEVELOPMENT_USER_HEADER", false)?;
Ok(AuthConfig {
wechat,
session_ttl_days,
allow_development_user_header,
})
}
fn speech_config() -> Result<Option<SpeechConfig>, String> {
let provider = optional_env("ASR_PROVIDER").unwrap_or_else(|| "local".to_owned());
if matches!(provider.as_str(), "disabled" | "none") {
return Ok(None);
}
let provider = match provider.as_str() {
"local" | "funasr" => SpeechProviderConfig::Local {
api_url: optional_env("LOCAL_ASR_URL")
.unwrap_or_else(|| "http://127.0.0.1:10095".to_owned())
.trim_end_matches('/')
.to_owned(),
},
"tencent" => {
let app_id = required_env("TENCENT_CLOUD_ASR_APP_ID")?
.parse()
.map_err(|_| "TENCENT_CLOUD_ASR_APP_ID must be an integer".to_owned())?;
SpeechProviderConfig::Tencent(TencentSpeechConfig {
app_id,
secret_id: required_env("TENCENT_CLOUD_ASR_SECRET_ID")?,
secret_key: required_env("TENCENT_CLOUD_ASR_SECRET_KEY")?,
engine_type: optional_env("TENCENT_CLOUD_ASR_ENGINE_TYPE")
.unwrap_or_else(|| "16k_zh".to_owned()),
})
}
_ => {
return Err(
"ASR_PROVIDER must be local, funasr, tencent, disabled, or none".to_owned(),
);
}
};
let request_timeout_seconds = parse_env("ASR_REQUEST_TIMEOUT_SECONDS", 1_800_u64)?;
let worker_concurrency = parse_env("ASR_WORKER_CONCURRENCY", 1_usize)?;
let job_lease_seconds = parse_env("ASR_JOB_LEASE_SECONDS", 3_600_i64)?;
if request_timeout_seconds == 0 {
return Err("ASR_REQUEST_TIMEOUT_SECONDS must be greater than 0".to_owned());
}
if worker_concurrency == 0 {
return Err("ASR_WORKER_CONCURRENCY must be greater than 0".to_owned());
}
if job_lease_seconds <= 0 {
return Err("ASR_JOB_LEASE_SECONDS must be greater than 0".to_owned());
}
Ok(Some(SpeechConfig {
provider,
request_timeout_seconds,
worker_concurrency,
job_lease_seconds,
}))
}
fn summary_config() -> Result<Option<SummaryConfig>, String> {
let Some(api_url) = optional_env("FEEDBACK_SUMMARY_API_URL") else {
return Ok(None);
};
let model = optional_env("FEEDBACK_SUMMARY_MODEL").ok_or_else(|| {
"FEEDBACK_SUMMARY_MODEL is required when summary API is configured".to_owned()
})?;
Ok(Some(SummaryConfig {
api_url: api_url.trim_end_matches('/').to_owned(),
api_key: optional_env("FEEDBACK_SUMMARY_API_KEY"),
model,
}))
}
fn optional_env(name: &str) -> Option<String> {
env::var(name)
.ok()
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
}
fn required_env(name: &str) -> Result<String, String> {
optional_env(name).ok_or_else(|| format!("{name} is required for the selected ASR provider"))
}
fn parse_env<T>(name: &str, default: T) -> Result<T, String>
where
T: std::str::FromStr,
{
optional_env(name).map_or_else(
|| Ok(default),
|value| value.parse().map_err(|_| format!("{name} is invalid")),
)
}
fn parse_bool_env(name: &str, default: bool) -> Result<bool, String> {
let Some(value) = optional_env(name) else {
return Ok(default);
};
match value.to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false),
_ => Err(format!("{name} must be true or false")),
}
}