//! 知识库 HTTP 处理器 use axum::{ extract::{Extension, Multipart, Path, Query, State}, Json, }; use crate::auth::types::AuthContext; use crate::error::{SaasError, SaasResult}; use crate::state::AppState; use super::service; use super::types::*; use super::extractors; // === 分类管理 === /// GET /api/v1/knowledge/categories — 树形分类列表 pub async fn list_categories( State(state): State, Extension(ctx): Extension, ) -> SaasResult>> { check_permission(&ctx, "knowledge:read")?; let tree = service::list_categories_tree(&state.db).await?; Ok(Json(tree)) } /// POST /api/v1/knowledge/categories — 创建分类 pub async fn create_category( State(state): State, Extension(ctx): Extension, Json(req): Json, ) -> SaasResult> { check_permission(&ctx, "knowledge:write")?; if req.name.trim().is_empty() { return Err(SaasError::InvalidInput("分类名称不能为空".into())); } let cat = service::create_category( &state.db, req.name.trim(), req.description.as_deref(), req.parent_id.as_deref(), req.icon.as_deref(), ).await?; Ok(Json(serde_json::json!({ "id": cat.id, "name": cat.name, }))) } /// PUT /api/v1/knowledge/categories/:id — 更新分类 pub async fn update_category( State(state): State, Extension(ctx): Extension, Path(id): Path, Json(req): Json, ) -> SaasResult> { check_permission(&ctx, "knowledge:write")?; if let Some(ref name) = req.name { if name.trim().is_empty() { return Err(SaasError::InvalidInput("分类名称不能为空".into())); } } let cat = service::update_category( &state.db, &id, req.name.as_deref().map(|n| n.trim()), req.description.as_deref(), req.parent_id.as_deref(), req.icon.as_deref(), ).await?; Ok(Json(serde_json::json!({ "id": cat.id, "name": cat.name, "updated": true, }))) } /// DELETE /api/v1/knowledge/categories/:id — 删除分类 pub async fn delete_category( State(state): State, Extension(ctx): Extension, Path(id): Path, ) -> SaasResult> { check_permission(&ctx, "knowledge:admin")?; service::delete_category(&state.db, &id).await?; Ok(Json(serde_json::json!({"deleted": true}))) } /// GET /api/v1/knowledge/categories/:id/items — 分类下条目列表 pub async fn list_category_items( State(state): State, Extension(ctx): Extension, Path(id): Path, Query(query): Query, ) -> SaasResult> { check_permission(&ctx, "knowledge:read")?; let page = query.page.unwrap_or(1).max(1); let page_size = query.page_size.unwrap_or(20).max(1).min(100); let status_filter = query.status.as_deref().unwrap_or("active"); let (items, total) = service::list_items_by_category( &state.db, &id, status_filter, page, page_size, ).await?; Ok(Json(serde_json::json!({ "items": items, "total": total, "page": page, "page_size": page_size, }))) } // === 知识条目 CRUD === /// GET /api/v1/knowledge/items — 分页列表 pub async fn list_items( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> SaasResult> { check_permission(&ctx, "knowledge:read")?; let page = query.page.unwrap_or(1).max(1).min(10000); let page_size = query.page_size.unwrap_or(20).max(1).min(100); let offset = (page - 1) * page_size; // 转义 ILIKE 通配符,防止用户输入的 % 和 _ 被当作通配符 let keyword = query.keyword.as_ref().map(|k| { k.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_") }); let items: Vec = sqlx::query_as( "SELECT ki.* FROM knowledge_items ki \ JOIN knowledge_categories kc ON ki.category_id = kc.id \ WHERE ($1::text IS NULL OR ki.category_id = $1) \ AND ($2::text IS NULL OR ki.status = $2) \ AND ($3::text IS NULL OR ki.title ILIKE '%' || $3 || '%') \ ORDER BY ki.priority DESC, ki.updated_at DESC \ LIMIT $4 OFFSET $5" ) .bind(&query.category_id) .bind(&query.status) .bind(&keyword) .bind(page_size) .bind(offset) .fetch_all(&state.db) .await?; let total: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM knowledge_items ki \ WHERE ($1::text IS NULL OR ki.category_id = $1) \ AND ($2::text IS NULL OR ki.status = $2) \ AND ($3::text IS NULL OR ki.title ILIKE '%' || $3 || '%')" ) .bind(&query.category_id) .bind(&query.status) .bind(&keyword) .fetch_one(&state.db) .await?; Ok(Json(serde_json::json!({ "items": items, "total": total.0, "page": page, "page_size": page_size, }))) } /// POST /api/v1/knowledge/items — 创建条目 pub async fn create_item( State(state): State, Extension(ctx): Extension, Json(req): Json, ) -> SaasResult> { check_permission(&ctx, "knowledge:write")?; if req.title.trim().is_empty() || req.content.trim().is_empty() { return Err(SaasError::InvalidInput("标题和内容不能为空".into())); } if req.content.len() > 100_000 { return Err(SaasError::InvalidInput("内容不能超过 100KB".into())); } let is_admin = ctx.role == "admin" || ctx.role == "super_admin"; let item = service::create_item(&state.db, &ctx.account_id, &req, is_admin).await?; // 异步触发 embedding 生成 if let Err(e) = state.worker_dispatcher.dispatch( "generate_embedding", serde_json::json!({ "item_id": item.id }), ).await { tracing::warn!("Failed to dispatch embedding generation: {}", e); } Ok(Json(serde_json::json!({ "id": item.id, "title": item.title, "version": item.version, }))) } /// POST /api/v1/knowledge/items/batch — 批量创建 pub async fn batch_create_items( State(state): State, Extension(ctx): Extension, Json(items): Json>, ) -> SaasResult> { check_permission(&ctx, "knowledge:write")?; if items.len() > 50 { return Err(SaasError::InvalidInput("单次批量创建不能超过 50 条".into())); } let is_admin = ctx.role == "admin" || ctx.role == "super_admin"; let mut created = Vec::new(); for req in &items { if req.title.trim().is_empty() || req.content.trim().is_empty() { tracing::warn!("Batch create: skipping item with empty title or content"); continue; } if req.content.len() > 100_000 { tracing::warn!("Batch create: skipping item '{}' (content too long)", req.title); continue; } match service::create_item(&state.db, &ctx.account_id, req, is_admin).await { Ok(item) => { if let Err(e) = state.worker_dispatcher.dispatch( "generate_embedding", serde_json::json!({ "item_id": item.id }), ).await { tracing::warn!("[Knowledge] Failed to dispatch embedding for item {}: {}", item.id, e); } created.push(item.id); } Err(e) => { tracing::warn!("Batch create item failed: {}", e); } } } Ok(Json(serde_json::json!({ "created_count": created.len(), "ids": created, }))) } /// GET /api/v1/knowledge/items/:id — 条目详情 pub async fn get_item( State(state): State, Extension(ctx): Extension, Path(id): Path, ) -> SaasResult> { check_permission(&ctx, "knowledge:read")?; let item = service::get_item(&state.db, &id).await? .ok_or_else(|| SaasError::NotFound("知识条目不存在".into()))?; Ok(Json(serde_json::to_value(item).unwrap_or_default())) } /// PUT /api/v1/knowledge/items/:id — 更新条目 pub async fn update_item( State(state): State, Extension(ctx): Extension, Path(id): Path, Json(req): Json, ) -> SaasResult> { check_permission(&ctx, "knowledge:write")?; if let Some(ref content) = req.content { if content.len() > 100_000 { return Err(SaasError::InvalidInput("内容不能超过 100KB".into())); } } let updated = service::update_item(&state.db, &id, &ctx.account_id, &req).await?; // 触发 re-embedding if let Err(e) = state.worker_dispatcher.dispatch( "generate_embedding", serde_json::json!({ "item_id": id }), ).await { tracing::warn!("[Knowledge] Failed to dispatch re-embedding for item {}: {}", id, e); } Ok(Json(serde_json::json!({ "id": updated.id, "version": updated.version, }))) } /// DELETE /api/v1/knowledge/items/:id — 删除条目 pub async fn delete_item( State(state): State, Extension(ctx): Extension, Path(id): Path, ) -> SaasResult> { check_permission(&ctx, "knowledge:admin")?; service::delete_item(&state.db, &id).await?; Ok(Json(serde_json::json!({"deleted": true}))) } // === 版本控制 === /// GET /api/v1/knowledge/items/:id/versions // @reserved - no frontend caller pub async fn list_versions( State(state): State, Extension(ctx): Extension, Path(id): Path, ) -> SaasResult> { check_permission(&ctx, "knowledge:read")?; let versions: Vec = sqlx::query_as( "SELECT * FROM knowledge_versions WHERE item_id = $1 ORDER BY version DESC" ) .bind(&id) .fetch_all(&state.db) .await?; Ok(Json(serde_json::json!({"versions": versions}))) } /// GET /api/v1/knowledge/items/:id/versions/:v // @reserved - no frontend caller pub async fn get_version( State(state): State, Extension(ctx): Extension, Path((id, v)): Path<(String, i32)>, ) -> SaasResult> { check_permission(&ctx, "knowledge:read")?; let version: KnowledgeVersion = sqlx::query_as( "SELECT * FROM knowledge_versions WHERE item_id = $1 AND version = $2" ) .bind(&id) .bind(v) .fetch_optional(&state.db) .await? .ok_or_else(|| SaasError::NotFound("版本不存在".into()))?; Ok(Json(serde_json::to_value(version).unwrap_or_default())) } /// POST /api/v1/knowledge/items/:id/rollback/:v // @reserved - no frontend caller pub async fn rollback_version( State(state): State, Extension(ctx): Extension, Path((id, v)): Path<(String, i32)>, ) -> SaasResult> { check_permission(&ctx, "knowledge:admin")?; let updated = service::rollback_version(&state.db, &id, v, &ctx.account_id).await?; // 触发 re-embedding if let Err(e) = state.worker_dispatcher.dispatch( "generate_embedding", serde_json::json!({ "item_id": id }), ).await { tracing::warn!("[Knowledge] Failed to dispatch re-embedding after rollback for item {}: {}", id, e); } Ok(Json(serde_json::json!({ "id": updated.id, "version": updated.version, "rolled_back_to": v, }))) } // === 检索 === /// POST /api/v1/knowledge/search — 统一搜索(双通道:文档 + 结构化) pub async fn search( State(state): State, Extension(ctx): Extension, Json(req): Json, ) -> SaasResult> { check_permission(&ctx, "knowledge:search")?; let results = service::unified_search( &state.db, &req, Some(&ctx.account_id), ).await?; Ok(Json(results)) } /// POST /api/v1/knowledge/recommend — 关联推荐 pub async fn recommend( State(state): State, Extension(ctx): Extension, Json(req): Json, ) -> SaasResult> { check_permission(&ctx, "knowledge:search")?; let mut req = req; req.min_score = Some(0.3); req.search_structured = req.search_structured.or(Some(true)); let results = service::unified_search( &state.db, &req, Some(&ctx.account_id), ).await?; Ok(Json(results)) } // === 分析看板 === /// GET /api/v1/knowledge/analytics/overview pub async fn analytics_overview( State(state): State, Extension(ctx): Extension, ) -> SaasResult> { check_permission(&ctx, "knowledge:read")?; let overview = service::analytics_overview(&state.db).await?; Ok(Json(overview)) } /// GET /api/v1/knowledge/analytics/trends pub async fn analytics_trends( State(state): State, Extension(ctx): Extension, ) -> SaasResult> { check_permission(&ctx, "knowledge:read")?; // 使用 serde_json::Value 行来避免 PgRow 序列化 let trends: Vec<(serde_json::Value,)> = sqlx::query_as( "SELECT json_build_object( 'date', DATE(created_at), 'count', COUNT(*), 'injected_count', SUM(CASE WHEN was_injected THEN 1 ELSE 0 END) ) as row \ FROM knowledge_usage \ WHERE created_at >= NOW() - interval '30 days' \ GROUP BY DATE(created_at) ORDER BY DATE(created_at)" ) .fetch_all(&state.db) .await .unwrap_or_default(); let trends: Vec = trends.into_iter().map(|(v,)| v).collect(); Ok(Json(serde_json::json!({"trends": trends}))) } /// GET /api/v1/knowledge/analytics/top-items pub async fn analytics_top_items( State(state): State, Extension(ctx): Extension, ) -> SaasResult> { check_permission(&ctx, "knowledge:read")?; let items: Vec<(serde_json::Value,)> = sqlx::query_as( "SELECT json_build_object( 'id', ki.id, 'title', ki.title, 'category', kc.name, 'ref_count', COUNT(ku.id) ) as row \ FROM knowledge_items ki \ JOIN knowledge_categories kc ON ki.category_id = kc.id \ LEFT JOIN knowledge_usage ku ON ku.item_id = ki.id \ WHERE ki.status = 'active' \ GROUP BY ki.id, ki.title, kc.name \ ORDER BY COUNT(ku.id) DESC LIMIT 20" ) .fetch_all(&state.db) .await .unwrap_or_default(); let items: Vec = items.into_iter().map(|(v,)| v).collect(); Ok(Json(serde_json::json!({"items": items}))) } /// GET /api/v1/knowledge/analytics/quality pub async fn analytics_quality( State(state): State, Extension(ctx): Extension, ) -> SaasResult> { check_permission(&ctx, "knowledge:read")?; let quality = service::analytics_quality(&state.db).await?; Ok(Json(quality)) } /// GET /api/v1/knowledge/analytics/gaps pub async fn analytics_gaps( State(state): State, Extension(ctx): Extension, ) -> SaasResult> { check_permission(&ctx, "knowledge:read")?; let gaps = service::analytics_gaps(&state.db).await?; Ok(Json(gaps)) } // === 批量操作 === /// PATCH /api/v1/knowledge/categories/reorder — 批量排序 pub async fn reorder_categories( State(state): State, Extension(ctx): Extension, Json(items): Json>, ) -> SaasResult> { check_permission(&ctx, "knowledge:write")?; if items.is_empty() { return Ok(Json(serde_json::json!({"reordered": false, "count": 0}))); } if items.len() > 100 { return Err(SaasError::InvalidInput("单次排序不能超过 100 个".into())); } // 使用事务保证原子性 let mut tx = state.db.begin().await?; for item in &items { sqlx::query("UPDATE knowledge_categories SET sort_order = $1, updated_at = NOW() WHERE id = $2") .bind(item.sort_order) .bind(&item.id) .execute(&mut *tx) .await?; } tx.commit().await?; Ok(Json(serde_json::json!({"reordered": true, "count": items.len()}))) } /// POST /api/v1/knowledge/items/import — Markdown 文件导入 pub async fn import_items( State(state): State, Extension(ctx): Extension, Json(req): Json, ) -> SaasResult> { check_permission(&ctx, "knowledge:write")?; if req.files.len() > 20 { return Err(SaasError::InvalidInput("单次导入不能超过 20 个文件".into())); } let is_admin = ctx.role == "admin" || ctx.role == "super_admin"; let mut created = Vec::new(); for file in &req.files { // 内容长度检查(数据库限制 100KB) if file.content.len() > 100_000 { tracing::warn!("跳过文件 '{}': 内容超长 ({} bytes)", file.title.as_deref().unwrap_or("未命名"), file.content.len()); continue; } // 空内容检查 if file.content.trim().is_empty() { tracing::warn!("跳过空文件: '{}'", file.title.as_deref().unwrap_or("未命名")); continue; } let title = file.title.clone().unwrap_or_else(|| { file.content.lines().next() .map(|l| l.trim_start_matches('#').trim().to_string()) .unwrap_or_else(|| format!("导入条目 {}", created.len() + 1)) }); let item_req = CreateItemRequest { category_id: req.category_id.clone(), title, content: file.content.clone(), keywords: file.keywords.clone(), related_questions: None, priority: None, tags: file.tags.clone(), visibility: None, }; match service::create_item(&state.db, &ctx.account_id, &item_req, is_admin).await { Ok(item) => { if let Err(e) = state.worker_dispatcher.dispatch( "generate_embedding", serde_json::json!({ "item_id": item.id }), ).await { tracing::warn!("[Knowledge] Failed to dispatch embedding for item {}: {}", item.id, e); } created.push(item.id); } Err(e) => { tracing::warn!("Import item '{}' failed: {}", item_req.title, e); } } } Ok(Json(serde_json::json!({ "created_count": created.len(), "ids": created, }))) } // === 辅助函数 === fn check_permission(ctx: &AuthContext, permission: &str) -> SaasResult<()> { crate::auth::handlers::check_permission(ctx, permission) } fn is_admin(ctx: &AuthContext) -> bool { ctx.role == "admin" || ctx.role == "super_admin" } // === 结构化数据源管理 === /// GET /api/v1/structured/sources pub async fn list_structured_sources( State(state): State, Extension(ctx): Extension, Query(query): Query, ) -> SaasResult> { check_permission(&ctx, "knowledge:read")?; let page = query.page.unwrap_or(1).max(1); let page_size = query.page_size.unwrap_or(20).max(1).min(100); let (sources, total) = service::list_structured_sources( &state.db, Some(&ctx.account_id), query.industry_id.as_deref(), query.status.as_deref(), page, page_size, ).await?; Ok(Json(serde_json::json!({ "items": sources, "total": total, "page": page, "page_size": page_size, }))) } /// GET /api/v1/structured/sources/:id pub async fn get_structured_source( State(state): State, Extension(ctx): Extension, Path(id): Path, ) -> SaasResult> { check_permission(&ctx, "knowledge:read")?; let source = service::get_structured_source(&state.db, &id, Some(&ctx.account_id)).await? .ok_or_else(|| SaasError::NotFound("数据源不存在".into()))?; Ok(Json(serde_json::to_value(source).unwrap_or_default())) } /// GET /api/v1/structured/sources/:id/rows pub async fn list_structured_source_rows( State(state): State, Extension(ctx): Extension, Path(id): Path, Query(query): Query, ) -> SaasResult> { check_permission(&ctx, "knowledge:read")?; let page = query.page.unwrap_or(1).max(1); let page_size = query.page_size.unwrap_or(50).max(1).min(200); let (rows, total) = service::list_structured_rows( &state.db, &id, Some(&ctx.account_id), query.sheet_name.as_deref(), page, page_size, ).await?; Ok(Json(serde_json::json!({ "rows": rows, "total": total, "page": page, "page_size": page_size, }))) } /// DELETE /api/v1/structured/sources/:id pub async fn delete_structured_source( State(state): State, Extension(ctx): Extension, Path(id): Path, ) -> SaasResult> { check_permission(&ctx, "knowledge:admin")?; service::delete_structured_source(&state.db, &id).await?; Ok(Json(serde_json::json!({"deleted": true}))) } /// POST /api/v1/structured/query pub async fn query_structured( State(state): State, Extension(ctx): Extension, Json(req): Json, ) -> SaasResult>> { check_permission(&ctx, "knowledge:search")?; let results = service::query_structured(&state.db, &req, Some(&ctx.account_id)).await?; Ok(Json(results)) } // === 文件上传 === /// POST /api/v1/knowledge/upload — multipart 文件上传 /// /// 支持 PDF/DOCX → RAG 管线,Excel → 结构化管线 pub async fn upload_file( State(state): State, Extension(ctx): Extension, mut multipart: Multipart, ) -> SaasResult> { check_permission(&ctx, "knowledge:write")?; let is_admin = ctx.role == "admin" || ctx.role == "super_admin"; let mut results = Vec::new(); while let Some(field) = multipart.next_field().await.map_err(|e| { SaasError::InvalidInput(format!("文件上传解析失败: {}", e)) })? { let file_name = field.file_name().unwrap_or("unknown").to_string(); let data = field.bytes().await.map_err(|e| { SaasError::InvalidInput(format!("文件读取失败: {}", e)) })?; // 大小限制 20MB if data.len() > 20 * 1024 * 1024 { results.push(serde_json::json!({ "file": file_name, "status": "error", "error": "文件超过 20MB 限制" })); continue; } let format = match extractors::detect_format(&file_name) { Some(f) => f, None => { results.push(serde_json::json!({ "file": file_name, "status": "error", "error": "不支持的文件格式" })); continue; } }; if format.is_structured() { // Excel → 结构化通道 match handle_structured_upload( &state, &ctx, is_admin, &data, &file_name, ).await { Ok(result) => results.push(result), Err(e) => results.push(serde_json::json!({ "file": file_name, "status": "error", "error": e.to_string() })), } } else { // PDF/DOCX/MD → 文档通道 (RAG) match handle_document_upload( &state, &ctx, is_admin, &data, &file_name, format, ).await { Ok(result) => results.push(result), Err(e) => results.push(serde_json::json!({ "file": file_name, "status": "error", "error": e.to_string() })), } } } Ok(Json(serde_json::json!({ "results": results, "count": results.len(), }))) } /// 处理文档类上传(PDF/DOCX/MD → RAG 管线) async fn handle_document_upload( state: &AppState, ctx: &AuthContext, is_admin: bool, data: &[u8], file_name: &str, format: extractors::DocumentFormat, ) -> SaasResult { let doc = match format { extractors::DocumentFormat::Pdf => extractors::extract_pdf(data, file_name)?, extractors::DocumentFormat::Docx => extractors::extract_docx(data, file_name)?, extractors::DocumentFormat::Markdown => { // Markdown 直通 let text = String::from_utf8_lossy(data).to_string(); let title = file_name.trim_end_matches(".md").trim_end_matches(".txt").to_string(); extractors::NormalizedDocument { title, sections: vec![extractors::DocumentSection { heading: None, content: text, level: 1, page_number: None, }], metadata: extractors::DocumentMetadata { source_format: "markdown".to_string(), file_name: file_name.to_string(), total_pages: None, total_sections: 1, }, } } _ => return Err(SaasError::InvalidInput("不支持的文档格式".into())), }; // 转为 Markdown 内容 let content = extractors::normalized_to_markdown(&doc); if content.is_empty() { return Err(SaasError::InvalidInput("文件内容为空".into())); } // 创建知识条目 let item_req = CreateItemRequest { category_id: "uploaded".to_string(), // FUTURE: post-release category_id from upload params title: doc.title.clone(), content, keywords: None, related_questions: None, priority: Some(5), tags: Some(vec![format!("source:{}", doc.metadata.source_format)]), visibility: None, }; let item = service::create_item(&state.db, &ctx.account_id, &item_req, is_admin).await?; // 触发分块 if let Err(e) = state.worker_dispatcher.dispatch( "generate_embedding", serde_json::json!({ "item_id": item.id }), ).await { tracing::warn!("Upload: failed to dispatch embedding for {}: {}", item.id, e); } Ok(serde_json::json!({ "file": file_name, "status": "ok", "item_id": item.id, "sections": doc.metadata.total_sections, "format": doc.metadata.source_format, })) } /// 处理结构化数据上传(Excel → structured_rows) async fn handle_structured_upload( state: &AppState, ctx: &AuthContext, is_admin: bool, data: &[u8], file_name: &str, ) -> SaasResult { let processed = extractors::extract_excel(data, file_name)?; match processed { extractors::ProcessedFile::Structured { title, sheet_names, column_headers, rows } => { if rows.is_empty() { return Err(SaasError::InvalidInput("Excel 文件没有数据行".into())); } // 创建结构化数据源 let source_req = CreateStructuredSourceRequest { title, description: None, original_file_name: Some(file_name.to_string()), sheet_names: Some(sheet_names.clone()), column_headers: Some(column_headers.clone()), visibility: None, industry_id: None, }; let source = service::create_structured_source( &state.db, &ctx.account_id, is_admin, &source_req, ).await?; // 批量写入行数据 let count = service::insert_structured_rows( &state.db, &source.id, &rows, ).await?; Ok(serde_json::json!({ "file": file_name, "status": "ok", "source_id": source.id, "sheets": sheet_names, "rows_imported": count, "columns": column_headers.len(), })) } _ => Err(SaasError::InvalidInput("意外的处理结果".into())), } } // === 种子知识冷启动 === /// POST /api/v1/knowledge/seed — 触发种子知识冷启动 /// /// 需要 admin 权限,幂等(按标题+行业查重) pub async fn seed_knowledge( State(state): State, Extension(ctx): Extension, Json(req): Json, ) -> SaasResult> { check_permission(&ctx, "knowledge:admin")?; if req.items.len() > 100 { return Err(SaasError::InvalidInput("单次种子不能超过 100 条".into())); } let created = service::seed_knowledge( &state.db, &req.industry_id, req.category_id.as_deref().unwrap_or("seed"), &req.items.iter().map(|i| (i.title.clone(), i.content.clone(), i.keywords.clone().unwrap_or_default())).collect::>(), &ctx.account_id, ).await?; Ok(Json(serde_json::json!({ "industry_id": req.industry_id, "created_count": created, "total_submitted": req.items.len(), }))) }