- backend: schema 加 cleaned 列;process_recording 流程 ASR → cleaning (LLM 分段+去口语+润色+**加粗高亮**) → summarizing → done - cleanup LLM 失败不阻塞,继续 summary - 前端三 block 顺序:纪要 → 清理润色 → 原文(details 折叠) - 新 status 'cleaning' 也加进 statusLabel / progressText
This commit is contained in:
@@ -118,9 +118,20 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="block">
|
<section class="block">
|
||||||
<h3>🎙️ 转写原文</h3>
|
<h3>✨ 清理润色</h3>
|
||||||
|
<p v-if="!selected.cleaned && selected.status === 'done'" class="muted">空(cleanup step 失败,看下方原文)</p>
|
||||||
|
<p v-else-if="['pending','transcribing','cleaning','summarizing'].includes(selected.status)" class="muted">
|
||||||
|
{{ progressText(selected.status) }}…
|
||||||
|
</p>
|
||||||
|
<div v-else class="prose" v-html="mdLite(selected.cleaned)"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<details>
|
||||||
|
<summary><h3 style="display:inline">🎙️ 转写原文(默认折叠)</h3></summary>
|
||||||
<p v-if="!selected.transcript" class="muted">{{ selected.status === 'failed' ? '转写失败' : '尚未生成' }}</p>
|
<p v-if="!selected.transcript" class="muted">{{ selected.status === 'failed' ? '转写失败' : '尚未生成' }}</p>
|
||||||
<pre v-else class="transcript">{{ selected.transcript }}</pre>
|
<pre v-else class="transcript">{{ selected.transcript }}</pre>
|
||||||
|
</details>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
@@ -392,7 +403,8 @@ function statusLabel(s) {
|
|||||||
return ({
|
return ({
|
||||||
pending: '⏳ 排队',
|
pending: '⏳ 排队',
|
||||||
transcribing: '🎙️ 转写中',
|
transcribing: '🎙️ 转写中',
|
||||||
summarizing: '✏️ 总结中',
|
cleaning: '✨ 清理润色中',
|
||||||
|
summarizing: '📋 总结中',
|
||||||
done: '✓ 完成',
|
done: '✓ 完成',
|
||||||
failed: '✗ 失败',
|
failed: '✗ 失败',
|
||||||
})[s] || s
|
})[s] || s
|
||||||
@@ -401,7 +413,8 @@ function progressText(s) {
|
|||||||
return ({
|
return ({
|
||||||
pending: '等候处理',
|
pending: '等候处理',
|
||||||
transcribing: '语音转写中(视音频长度可能要几分钟)',
|
transcribing: '语音转写中(视音频长度可能要几分钟)',
|
||||||
summarizing: 'LLM 生成纪要中',
|
cleaning: 'LLM 分段 + 去口语 + 润色 + 高亮',
|
||||||
|
summarizing: 'LLM 生成会议纪要',
|
||||||
})[s] || s
|
})[s] || s
|
||||||
}
|
}
|
||||||
function fmtSize(b) {
|
function fmtSize(b) {
|
||||||
@@ -740,6 +753,29 @@ input, textarea { font-family: inherit; background: transparent; border: none; c
|
|||||||
}
|
}
|
||||||
.block.err pre { white-space: pre-wrap; color: var(--accent-red); font-size: 12px; }
|
.block.err pre { white-space: pre-wrap; color: var(--accent-red); font-size: 12px; }
|
||||||
|
|
||||||
|
.block details > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
user-select: none;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.block details > summary::-webkit-details-marker { display: none; }
|
||||||
|
.block details > summary::before {
|
||||||
|
content: '▶';
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-mute);
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.block details[open] > summary::before { transform: rotate(90deg); }
|
||||||
|
.block details > summary h3 {
|
||||||
|
margin: 0 !important;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.root { flex-direction: column; }
|
.root { flex-direction: column; }
|
||||||
.sidebar { width: 100%; height: 45vh; border-right: none; border-bottom: 1px solid var(--border-soft); }
|
.sidebar { width: 100%; height: 45vh; border-right: none; border-bottom: 1px solid var(--border-soft); }
|
||||||
|
|||||||
+83
-8
@@ -81,6 +81,7 @@ async fn main() -> std::io::Result<()> {
|
|||||||
// 兼容旧 db 增量加列;已存在忽略错误
|
// 兼容旧 db 增量加列;已存在忽略错误
|
||||||
let _ = conn.execute("ALTER TABLE recordings ADD COLUMN feishu_doc_id TEXT", []);
|
let _ = conn.execute("ALTER TABLE recordings ADD COLUMN feishu_doc_id TEXT", []);
|
||||||
let _ = conn.execute("ALTER TABLE recordings ADD COLUMN feishu_url TEXT", []);
|
let _ = conn.execute("ALTER TABLE recordings ADD COLUMN feishu_url TEXT", []);
|
||||||
|
let _ = conn.execute("ALTER TABLE recordings ADD COLUMN cleaned TEXT", []);
|
||||||
tracing::info!(%db_path, blobs = %blobs_dir.display(), "notes ready");
|
tracing::info!(%db_path, blobs = %blobs_dir.display(), "notes ready");
|
||||||
|
|
||||||
let http = reqwest::Client::builder()
|
let http = reqwest::Client::builder()
|
||||||
@@ -247,6 +248,7 @@ struct RecordingDetail {
|
|||||||
size_bytes: i64,
|
size_bytes: i64,
|
||||||
status: String,
|
status: String,
|
||||||
transcript: Option<String>,
|
transcript: Option<String>,
|
||||||
|
cleaned: Option<String>,
|
||||||
summary: Option<String>,
|
summary: Option<String>,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
created_at: String,
|
created_at: String,
|
||||||
@@ -293,30 +295,30 @@ async fn get_recording(
|
|||||||
let conn = s.db.lock().unwrap();
|
let conn = s.db.lock().unwrap();
|
||||||
type Row = (
|
type Row = (
|
||||||
String, String, String, i64, String,
|
String, String, String, i64, String,
|
||||||
Option<String>, Option<String>, Option<String>, String,
|
Option<String>, Option<String>, Option<String>, Option<String>, String,
|
||||||
Option<String>, Option<String>,
|
Option<String>, Option<String>,
|
||||||
);
|
);
|
||||||
let row: Option<Row> = conn
|
let row: Option<Row> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT title, filename, mime, size_bytes, status,
|
"SELECT title, filename, mime, size_bytes, status,
|
||||||
transcript, summary, error, created_at,
|
transcript, cleaned, summary, error, created_at,
|
||||||
feishu_doc_id, feishu_url
|
feishu_doc_id, feishu_url
|
||||||
FROM recordings WHERE id = ?1",
|
FROM recordings WHERE id = ?1",
|
||||||
params![id],
|
params![id],
|
||||||
|r| {
|
|r| {
|
||||||
Ok((
|
Ok((
|
||||||
r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?,
|
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(5)?, r.get(6)?, r.get(7)?, r.get(8)?, r.get(9)?,
|
||||||
r.get(9)?, r.get(10)?,
|
r.get(10)?, r.get(11)?,
|
||||||
))
|
))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.optional()?;
|
.optional()?;
|
||||||
let (title, filename, mime, size_bytes, status, transcript, summary, error, created_at,
|
let (title, filename, mime, size_bytes, status, transcript, cleaned, summary, error, created_at,
|
||||||
feishu_doc_id, feishu_url) = row.ok_or(AppError::NotFound)?;
|
feishu_doc_id, feishu_url) = row.ok_or(AppError::NotFound)?;
|
||||||
Ok(JsonResp(RecordingDetail {
|
Ok(JsonResp(RecordingDetail {
|
||||||
id, title, filename, mime, size_bytes, status,
|
id, title, filename, mime, size_bytes, status,
|
||||||
transcript, summary, error, created_at,
|
transcript, cleaned, summary, error, created_at,
|
||||||
feishu_doc_id, feishu_url,
|
feishu_doc_id, feishu_url,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -455,15 +457,34 @@ async fn process_recording(s: AppState, id: i64) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// 写 transcript 但还没 summary
|
// 写 transcript,进入 cleaning
|
||||||
{
|
{
|
||||||
let conn = s.db.lock().unwrap();
|
let conn = s.db.lock().unwrap();
|
||||||
let _ = conn.execute(
|
let _ = conn.execute(
|
||||||
"UPDATE recordings SET transcript = ?1, status = 'summarizing' WHERE id = ?2",
|
"UPDATE recordings SET transcript = ?1, status = 'cleaning' WHERE id = ?2",
|
||||||
params![&transcript, id],
|
params![&transcript, id],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LLM cleanup:分段 + 去口语 + 润色 + 高亮(失败也继续 summary,不阻塞)
|
||||||
|
match call_llm_cleanup(&s, &transcript).await {
|
||||||
|
Ok(c) => {
|
||||||
|
let conn = s.db.lock().unwrap();
|
||||||
|
let _ = conn.execute(
|
||||||
|
"UPDATE recordings SET cleaned = ?1, status = 'summarizing' WHERE id = ?2",
|
||||||
|
params![&c, id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(%id, error = %e, "cleanup failed, skip and continue to summary");
|
||||||
|
let conn = s.db.lock().unwrap();
|
||||||
|
let _ = conn.execute(
|
||||||
|
"UPDATE recordings SET status = 'summarizing' WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// LLM:生成会议纪要 + 标题
|
// LLM:生成会议纪要 + 标题
|
||||||
let raw = match call_llm_summary(&s, &transcript).await {
|
let raw = match call_llm_summary(&s, &transcript).await {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
@@ -559,6 +580,60 @@ async fn call_asr(
|
|||||||
Ok(text)
|
Ok(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn call_llm_cleanup(s: &AppState, transcript: &str) -> Result<String, String> {
|
||||||
|
let trimmed = if transcript.chars().count() > 12000 {
|
||||||
|
let mut out = String::new();
|
||||||
|
for (i, c) in transcript.chars().enumerate() {
|
||||||
|
if i >= 12000 { break; }
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
out + "\n\n[... 后文截断]"
|
||||||
|
} else {
|
||||||
|
transcript.to_string()
|
||||||
|
};
|
||||||
|
let payload = json!({
|
||||||
|
"model": s.llm_model,
|
||||||
|
"messages": [
|
||||||
|
{ "role": "system", "content":
|
||||||
|
"你是 ASR 转写后处理助手。把下面这段连续无标点的转写整理成可读版本:\n\
|
||||||
|
\n\
|
||||||
|
1. **自动分段**:按话题/语义换段,每段 2-5 句\n\
|
||||||
|
2. **加标点**:句号、问号、感叹号、逗号、引号\n\
|
||||||
|
3. **去口语噪音**:删掉「嗯/啊/那个/就是/对/然后...」等填充词,但保留实际含义的连接词\n\
|
||||||
|
4. **轻度润色**:通顺、语法、错别字(结合上下文修 ASR 错字),但**不要总结、不要改变原意、不要添加内容**\n\
|
||||||
|
5. **重点高亮**:把关键判断、结论、决定、数字、名词用 markdown `**...**` 加粗\n\
|
||||||
|
\n\
|
||||||
|
输出纯 markdown 段落,不要标题、不要列表、不要解释。" },
|
||||||
|
{ "role": "user", "content": trimmed },
|
||||||
|
],
|
||||||
|
"temperature": 0.3,
|
||||||
|
});
|
||||||
|
let url = format!("{}/chat/completions", s.llm_gateway.trim_end_matches('/'));
|
||||||
|
let resp = s
|
||||||
|
.http
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(&s.llm_token)
|
||||||
|
.json(&payload)
|
||||||
|
.timeout(std::time::Duration::from_secs(600))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("connect: {e}"))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let st = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("LLM {st}: {body}"));
|
||||||
|
}
|
||||||
|
let v: Value = resp.json().await.map_err(|e| format!("decode: {e}"))?;
|
||||||
|
let text = v
|
||||||
|
.get("choices").and_then(|c| c.get(0))
|
||||||
|
.and_then(|c| c.get("message"))
|
||||||
|
.and_then(|m| m.get("content"))
|
||||||
|
.and_then(|c| c.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.ok_or_else(|| format!("LLM no content: {v}"))?;
|
||||||
|
Ok(text)
|
||||||
|
}
|
||||||
|
|
||||||
async fn call_llm_summary(s: &AppState, transcript: &str) -> Result<String, String> {
|
async fn call_llm_summary(s: &AppState, transcript: &str) -> Result<String, String> {
|
||||||
let trimmed = if transcript.chars().count() > 12000 {
|
let trimmed = if transcript.chars().count() > 12000 {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
|
|||||||
Reference in New Issue
Block a user