notes: 加一键转飞书文档 (sidecar markdown-to-feishu)
deploy notes / build-and-deploy (push) Failing after 2m2s
deploy notes / build-and-deploy (push) Failing after 2m2s
- backend: POST /api/recordings/:id/feishu → 拼 markdown (总结在最上 + 附件链接到转录/录音 + 转写全文) → 写 /data/feishu-tmp/<id>/ → HTTP POST 到 feishu sidecar
- 复用:已有 feishu_doc_id 时 --update 同一个 doc,前端按钮文案变「↻ 重新生成」
- schema 加 feishu_doc_id + feishu_url 两列(ALTER TABLE 兼容旧 db)
- LLM prompt 改:行动项用 markdown checkbox `- [ ] 谁·做什么·何时`
- sidecar apps/notes/feishu: node:20 + python3 + python3-markdown + @larksuite/cli + COPY 自己的 markdown-to-feishu script + FastAPI /convert
- k8s: deployment 加 feishu container 共享 PVC;lark-cli-creds Secret 挂 /root/.lark-cli/config.json
- CI: 主 image --no-cache(cube 规矩),sidecar 保留 layer cache(chromium-free,但 apt/npm 也大)
- 前端: content 头部加「📤 一键转飞书文档」按钮;已转过显示飞书链接 + 按钮变重生成
This commit is contained in:
+125
-7
@@ -36,6 +36,7 @@ struct AppState {
|
||||
llm_gateway: String,
|
||||
llm_token: String,
|
||||
llm_model: String,
|
||||
feishu_url: String,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
@@ -59,6 +60,8 @@ async fn main() -> std::io::Result<()> {
|
||||
std::env::var("LLM_GATEWAY").unwrap_or_else(|_| "http://3.135.65.204:8848/v1".into());
|
||||
let llm_token = std::env::var("LLM_TOKEN").unwrap_or_default();
|
||||
let llm_model = std::env::var("LLM_MODEL").unwrap_or_else(|_| "gemma-4-31b-it".into());
|
||||
let feishu_url =
|
||||
std::env::var("FEISHU_URL").unwrap_or_else(|_| "http://localhost:8002".into());
|
||||
|
||||
std::fs::create_dir_all(&blobs_dir).expect("mkdir blobs_dir");
|
||||
|
||||
@@ -79,6 +82,9 @@ async fn main() -> std::io::Result<()> {
|
||||
);",
|
||||
)
|
||||
.expect("init schema");
|
||||
// 兼容旧 db 增量加列;已存在忽略错误
|
||||
let _ = conn.execute("ALTER TABLE recordings ADD COLUMN feishu_doc_id TEXT", []);
|
||||
let _ = conn.execute("ALTER TABLE recordings ADD COLUMN feishu_url TEXT", []);
|
||||
tracing::info!(%db_path, blobs = %blobs_dir.display(), "notes ready");
|
||||
|
||||
let http = reqwest::Client::builder()
|
||||
@@ -94,6 +100,7 @@ async fn main() -> std::io::Result<()> {
|
||||
llm_gateway,
|
||||
llm_token,
|
||||
llm_model,
|
||||
feishu_url,
|
||||
http,
|
||||
};
|
||||
|
||||
@@ -105,6 +112,7 @@ async fn main() -> std::io::Result<()> {
|
||||
.route("/recordings/:id", get(get_recording).delete(delete_recording))
|
||||
.route("/recordings/:id/audio", get(stream_audio))
|
||||
.route("/recordings/:id/retry", post(retry_recording))
|
||||
.route("/recordings/:id/feishu", post(convert_feishu))
|
||||
.with_state(state.clone())
|
||||
.layer(from_fn_with_state(state.clone(), auth_middleware));
|
||||
|
||||
@@ -211,6 +219,8 @@ struct RecordingDetail {
|
||||
summary: Option<String>,
|
||||
error: Option<String>,
|
||||
created_at: String,
|
||||
feishu_doc_id: Option<String>,
|
||||
feishu_url: Option<String>,
|
||||
}
|
||||
|
||||
// ---------- handlers ----------
|
||||
@@ -253,26 +263,30 @@ async fn get_recording(
|
||||
type Row = (
|
||||
String, String, String, i64, String,
|
||||
Option<String>, Option<String>, Option<String>, String,
|
||||
Option<String>, Option<String>,
|
||||
);
|
||||
let row: Option<Row> = conn
|
||||
.query_row(
|
||||
"SELECT title, filename, mime, size_bytes, status,
|
||||
transcript, summary, error, created_at
|
||||
transcript, summary, error, created_at,
|
||||
feishu_doc_id, feishu_url
|
||||
FROM recordings WHERE id = ?1",
|
||||
params![id],
|
||||
|r| {
|
||||
Ok((
|
||||
r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?,
|
||||
r.get(5)?, r.get(6)?, r.get(7)?, r.get(8)?,
|
||||
r.get(9)?, r.get(10)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
let (title, filename, mime, size_bytes, status, transcript, summary, error, created_at) =
|
||||
row.ok_or(AppError::NotFound)?;
|
||||
let (title, filename, mime, size_bytes, status, transcript, summary, error, created_at,
|
||||
feishu_doc_id, feishu_url) = row.ok_or(AppError::NotFound)?;
|
||||
Ok(JsonResp(RecordingDetail {
|
||||
id, title, filename, mime, size_bytes, status,
|
||||
transcript, summary, error, created_at,
|
||||
feishu_doc_id, feishu_url,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -501,13 +515,16 @@ async fn call_llm_summary(s: &AppState, transcript: &str) -> Result<String, Stri
|
||||
"model": s.llm_model,
|
||||
"messages": [
|
||||
{ "role": "system", "content":
|
||||
"你是一个会议纪要助手。根据语音转写整理一份结构化纪要(markdown):\n\
|
||||
"你是一个会议纪要助手。根据语音转写整理一份结构化纪要(markdown 格式):\n\
|
||||
\n\
|
||||
1. **概要**:1-2 句话总结\n\
|
||||
2. **关键讨论点**:bullet 列出\n\
|
||||
3. **决定 / 结论**\n\
|
||||
4. **行动项 (action items)**:谁、做什么、何时\n\
|
||||
5. **待跟进 / 未决问题**\n\
|
||||
转写可能有 ASR 错字,结合上下文合理修正;遇到模糊处标 [?]。" },
|
||||
4. **行动项 (action items)**:每条用 markdown checkbox 格式 `- [ ] 谁 · 做什么 · 何时`\n\
|
||||
5. **待跟进 / 未决问题**:bullet 列出\n\
|
||||
\n\
|
||||
转写可能有 ASR 错字,结合上下文合理修正;遇到模糊处标 [?]。\n\
|
||||
不要编造没说过的内容。" },
|
||||
{ "role": "user", "content": trimmed },
|
||||
],
|
||||
"temperature": 0.3,
|
||||
@@ -574,6 +591,107 @@ async fn retry_recording(
|
||||
Ok(JsonResp(json!({ "ok": true, "status": "pending" })))
|
||||
}
|
||||
|
||||
/// `POST /api/recordings/:id/feishu` — 把转写 + 纪要 push 成飞书 docx。
|
||||
/// 已经转过的 piece 仍 update 同一个 doc(markdown-to-feishu 自带 --update)。
|
||||
async fn convert_feishu(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<JsonResp<Value>, AppError> {
|
||||
let row: (String, String, Option<String>, Option<String>, String, Option<String>) = {
|
||||
let conn = s.db.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT title, filename, transcript, summary, status, feishu_doc_id
|
||||
FROM recordings WHERE id = ?1",
|
||||
params![id],
|
||||
|r| {
|
||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?))
|
||||
},
|
||||
)
|
||||
.optional()?
|
||||
.ok_or(AppError::NotFound)?
|
||||
};
|
||||
let (title, filename, transcript, summary, status, existing_doc) = row;
|
||||
if status != "done" {
|
||||
return Err(AppError::bad_request(format!(
|
||||
"recording not ready (status={status})"
|
||||
)));
|
||||
}
|
||||
let summary = summary.unwrap_or_default();
|
||||
let transcript = transcript.unwrap_or_default();
|
||||
|
||||
// 拼 markdown
|
||||
let ext = std::path::Path::new(&filename)
|
||||
.extension()
|
||||
.and_then(|x| x.to_str())
|
||||
.unwrap_or("m4a")
|
||||
.to_string();
|
||||
let audio_name = format!("audio.{ext}");
|
||||
let md = format!(
|
||||
"# {title}\n\n\
|
||||
## 📋 会议纪要\n\n\
|
||||
{summary}\n\n\
|
||||
---\n\n\
|
||||
## 📎 原始材料\n\n\
|
||||
- [📄 转录原文](./transcript.txt)\n\
|
||||
- [🎙️ 原始录音](./{audio_name})\n\n\
|
||||
---\n\n\
|
||||
## 🎙️ 转录全文\n\n\
|
||||
{transcript}\n",
|
||||
);
|
||||
|
||||
// 落到 PVC 共享目录,sidecar 同样挂这个卷
|
||||
let work_dir = std::path::PathBuf::from(format!("/data/feishu-tmp/{id}"));
|
||||
tokio::fs::create_dir_all(&work_dir).await.map_err(AppError::Io)?;
|
||||
let md_path = work_dir.join("note.md");
|
||||
tokio::fs::write(&md_path, md).await.map_err(AppError::Io)?;
|
||||
tokio::fs::write(work_dir.join("transcript.txt"), &transcript)
|
||||
.await
|
||||
.map_err(AppError::Io)?;
|
||||
// 拷 audio(用 copy,sidecar 跑期间不会被改)
|
||||
let audio_src = s.blobs_dir.join(id.to_string());
|
||||
let audio_dst = work_dir.join(&audio_name);
|
||||
tokio::fs::copy(&audio_src, &audio_dst).await.map_err(AppError::Io)?;
|
||||
|
||||
// 调 sidecar
|
||||
let url = format!("{}/convert", s.feishu_url.trim_end_matches('/'));
|
||||
let mut payload = json!({
|
||||
"md_path": md_path.to_string_lossy(),
|
||||
"title": title,
|
||||
});
|
||||
if let Some(d) = existing_doc.as_deref().filter(|x| !x.is_empty()) {
|
||||
payload["existing_doc_id"] = json!(d);
|
||||
}
|
||||
let resp = s
|
||||
.http
|
||||
.post(&url)
|
||||
.json(&payload)
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::bad_request(format!("feishu sidecar: {e}")))?;
|
||||
if !resp.status().is_success() {
|
||||
let st = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(AppError::bad_request(format!("feishu {st}: {body}")));
|
||||
}
|
||||
let body: Value = resp.json().await.map_err(|e| AppError::bad_request(format!("decode: {e}")))?;
|
||||
let doc_id = body.get("doc_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let doc_url = body.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
if doc_id.is_empty() || doc_url.is_empty() {
|
||||
return Err(AppError::bad_request(format!("feishu bad response: {body}")));
|
||||
}
|
||||
|
||||
{
|
||||
let conn = s.db.lock().unwrap();
|
||||
conn.execute(
|
||||
"UPDATE recordings SET feishu_doc_id = ?1, feishu_url = ?2 WHERE id = ?3",
|
||||
params![&doc_id, &doc_url, id],
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(JsonResp(json!({ "doc_id": doc_id, "url": doc_url })))
|
||||
}
|
||||
|
||||
async fn stream_audio(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
|
||||
Reference in New Issue
Block a user