notes: 加一键转飞书文档 (sidecar markdown-to-feishu)
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:
Fam Zheng
2026-05-17 22:16:13 +01:00
parent 3a34fbdfd8
commit 68671784f6
8 changed files with 1327 additions and 11 deletions
+125 -7
View File
@@ -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 同一个 docmarkdown-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(用 copysidecar 跑期间不会被改)
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>,