mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-07-07 23:51:21 +08:00
🐞 fix: 增加错误之后对已解析段落的缓存功能,再次重试时不再重头开始
解析长视频时,当附件大小过大时不再调用后进行报错,而是将附件进行分批次发送 在每篇笔记开头默认增加地址来源链接,对模糊处可溯源
This commit is contained in:
@@ -18,12 +18,12 @@ BASE_PROMPT = '''
|
||||
- **不要**将输出包裹在代码块中(例如:```` ```markdown ````,```` ``` ````)。
|
||||
请注意,在生成 Markdown 时,避免将编号标题(如“1. **内容**”)写成有序列表的格式,以免解析错误。
|
||||
|
||||
- 如果要加粗并保留编号,应使用 `1\. **内容**`(加反斜杠),防止被误解析为有序列表。
|
||||
- 如果要加粗并保留编号,应使用 `1\\. **内容**`(加反斜杠),防止被误解析为有序列表。
|
||||
- 或者使用 `## 1. 内容` 的形式作为标题。
|
||||
|
||||
请确保以下格式 **不会出现误渲染**:
|
||||
`1. **xxx**`
|
||||
`1\. **xxx**` 或 `## 1. xxx`
|
||||
`1\\. **xxx**` 或 `## 1. xxx`
|
||||
|
||||
视频分段(格式:开始时间 - 内容):
|
||||
|
||||
@@ -66,4 +66,13 @@ SCREENSHOT='''
|
||||
8. **Screenshot placeholders**: If a section involves **visual demonstrations, code walkthroughs, UI interactions**, or any content where visuals aid understanding, insert a screenshot cue at the end of that section:
|
||||
- Format: `*Screenshot-[mm:ss]`
|
||||
- Only use it when truly helpful.
|
||||
'''
|
||||
'''
|
||||
|
||||
MERGE_PROMPT = '''
|
||||
你将收到多个来自同一视频的 Markdown 笔记片段,请合并成一份完整笔记:
|
||||
- 只做合并与去重,不要发明新内容
|
||||
- 保持原有标题层级与 Markdown 结构
|
||||
- 保留所有 *Content-[mm:ss] 与 *Screenshot-[mm:ss] 标记
|
||||
- 保持中文输出,专有名词保留英文
|
||||
- 不要使用代码块包裹输出
|
||||
'''
|
||||
|
||||
161
backend/app/gpt/request_chunker.py
Normal file
161
backend/app/gpt/request_chunker.py
Normal file
@@ -0,0 +1,161 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChunkPayload:
|
||||
segments: list
|
||||
image_urls: list
|
||||
|
||||
|
||||
class RequestChunker:
|
||||
def __init__(self, message_builder: Callable, max_bytes: int, size_estimator: Optional[Callable] = None):
|
||||
self.message_builder = message_builder
|
||||
self.max_bytes = max_bytes
|
||||
self.size_estimator = size_estimator
|
||||
|
||||
def estimate(self, messages) -> int:
|
||||
if self.size_estimator:
|
||||
return self.size_estimator(messages)
|
||||
import json
|
||||
return len(json.dumps(messages, ensure_ascii=False).encode("utf-8"))
|
||||
|
||||
def _messages_size(self, segments, image_urls, **kwargs) -> int:
|
||||
messages = self.message_builder(segments, image_urls, **kwargs)
|
||||
return self.estimate(messages)
|
||||
|
||||
def _get_text(self, segment) -> str:
|
||||
if isinstance(segment, dict):
|
||||
return segment.get("text", "")
|
||||
return getattr(segment, "text", "")
|
||||
|
||||
def _make_segment(self, segment, text: str):
|
||||
if isinstance(segment, dict):
|
||||
new_seg = dict(segment)
|
||||
new_seg["text"] = text
|
||||
return new_seg
|
||||
if hasattr(segment, "__dict__"):
|
||||
data = dict(segment.__dict__)
|
||||
data["text"] = text
|
||||
return type(segment)(**data)
|
||||
return type(segment)(segment.start, segment.end, text)
|
||||
|
||||
def _split_segment_to_fit(self, segment, **kwargs):
|
||||
text = self._get_text(segment)
|
||||
if not text:
|
||||
raise ValueError("empty segment cannot be split")
|
||||
lo, hi = 1, len(text)
|
||||
best = None
|
||||
while lo <= hi:
|
||||
mid = (lo + hi) // 2
|
||||
candidate = self._make_segment(segment, text[:mid])
|
||||
size = self._messages_size([candidate], [], **kwargs)
|
||||
if size <= self.max_bytes:
|
||||
best = mid
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid - 1
|
||||
if best is None:
|
||||
raise ValueError("single segment too large to fit request")
|
||||
head = self._make_segment(segment, text[:best])
|
||||
tail = self._make_segment(segment, text[best:])
|
||||
return head, tail
|
||||
|
||||
def chunk(self, segments: list, image_urls: list, **kwargs) -> List[ChunkPayload]:
|
||||
segments = list(segments or [])
|
||||
image_urls = list(image_urls or [])
|
||||
if not segments and not image_urls:
|
||||
return []
|
||||
|
||||
chunks: List[ChunkPayload] = []
|
||||
seg_idx = 0
|
||||
|
||||
while seg_idx < len(segments):
|
||||
batch_segments = []
|
||||
while seg_idx < len(segments):
|
||||
candidate = batch_segments + [segments[seg_idx]]
|
||||
size = self._messages_size(candidate, [], **kwargs)
|
||||
if size <= self.max_bytes:
|
||||
batch_segments = candidate
|
||||
seg_idx += 1
|
||||
continue
|
||||
if not batch_segments:
|
||||
head, tail = self._split_segment_to_fit(segments[seg_idx], **kwargs)
|
||||
segments[seg_idx] = head
|
||||
segments.insert(seg_idx + 1, tail)
|
||||
continue
|
||||
break
|
||||
|
||||
if not batch_segments:
|
||||
raise ValueError("unable to fit any content into chunk")
|
||||
|
||||
chunks.append(ChunkPayload(segments=batch_segments, image_urls=[]))
|
||||
|
||||
if not image_urls:
|
||||
return chunks
|
||||
|
||||
if not chunks:
|
||||
chunks = [ChunkPayload(segments=[], image_urls=[])]
|
||||
|
||||
if not segments:
|
||||
for image in image_urls:
|
||||
appended = False
|
||||
for chunk in chunks[-1:]:
|
||||
candidate_images = chunk.image_urls + [image]
|
||||
if self._messages_size(chunk.segments, candidate_images, **kwargs) <= self.max_bytes:
|
||||
chunk.image_urls = candidate_images
|
||||
appended = True
|
||||
break
|
||||
|
||||
if appended:
|
||||
continue
|
||||
|
||||
if self._messages_size([], [image], **kwargs) > self.max_bytes:
|
||||
raise ValueError("single image payload exceeds max_bytes")
|
||||
chunks.append(ChunkPayload(segments=[], image_urls=[image]))
|
||||
return chunks
|
||||
|
||||
chunk_count = len(chunks)
|
||||
total_images = len(image_urls)
|
||||
for idx, image in enumerate(image_urls):
|
||||
preferred_idx = min(chunk_count - 1, (idx * chunk_count) // total_images)
|
||||
placed = False
|
||||
|
||||
for chunk_idx in range(preferred_idx, len(chunks)):
|
||||
chunk = chunks[chunk_idx]
|
||||
candidate_images = chunk.image_urls + [image]
|
||||
if self._messages_size(chunk.segments, candidate_images, **kwargs) <= self.max_bytes:
|
||||
chunk.image_urls = candidate_images
|
||||
placed = True
|
||||
break
|
||||
|
||||
if placed:
|
||||
continue
|
||||
|
||||
if self._messages_size([], [image], **kwargs) > self.max_bytes:
|
||||
raise ValueError("single image payload exceeds max_bytes")
|
||||
chunks.append(ChunkPayload(segments=[], image_urls=[image]))
|
||||
|
||||
return chunks
|
||||
|
||||
def group_texts_by_budget(self, texts: List[str], build_messages: Callable, **kwargs) -> List[List[str]]:
|
||||
groups: List[List[str]] = []
|
||||
idx = 0
|
||||
while idx < len(texts):
|
||||
group: List[str] = []
|
||||
while idx < len(texts):
|
||||
candidate = group + [texts[idx]]
|
||||
try:
|
||||
messages = build_messages(candidate, [], **kwargs)
|
||||
except TypeError:
|
||||
messages = build_messages(candidate, **kwargs)
|
||||
size = self.estimate(messages)
|
||||
if size <= self.max_bytes:
|
||||
group = candidate
|
||||
idx += 1
|
||||
continue
|
||||
if not group:
|
||||
raise ValueError("single text block exceeds max_bytes")
|
||||
break
|
||||
groups.append(group)
|
||||
return groups
|
||||
@@ -1,8 +1,16 @@
|
||||
from app.gpt.base import GPT
|
||||
from app.gpt.prompt_builder import generate_base_prompt
|
||||
from app.models.gpt_model import GPTSource
|
||||
from app.gpt.prompt import BASE_PROMPT, AI_SUM, SCREENSHOT, LINK
|
||||
import os
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app.gpt.prompt import BASE_PROMPT, AI_SUM, SCREENSHOT, LINK, MERGE_PROMPT
|
||||
from app.gpt.utils import fix_markdown
|
||||
from app.gpt.request_chunker import RequestChunker
|
||||
from app.models.transcriber_model import TranscriptSegment
|
||||
from datetime import timedelta
|
||||
from typing import List
|
||||
@@ -15,6 +23,9 @@ class UniversalGPT(GPT):
|
||||
self.temperature = temperature
|
||||
self.screenshot = False
|
||||
self.link = False
|
||||
self.max_request_bytes = int(os.getenv("OPENAI_MAX_REQUEST_BYTES", str(45 * 1024 * 1024)))
|
||||
self.checkpoint_dir = Path(os.getenv("NOTE_OUTPUT_DIR", "note_results"))
|
||||
self.checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _format_time(self, seconds: float) -> str:
|
||||
return str(timedelta(seconds=int(seconds)))[2:]
|
||||
@@ -40,7 +51,7 @@ class UniversalGPT(GPT):
|
||||
)
|
||||
|
||||
# ⛳ 组装 content 数组,支持 text + image_url 混合
|
||||
content = [{"type": "text", "text": content_text}]
|
||||
content: List[dict] = [{"type": "text", "text": content_text}]
|
||||
video_img_urls = kwargs.get('video_img_urls', [])
|
||||
|
||||
for url in video_img_urls:
|
||||
@@ -63,23 +74,234 @@ class UniversalGPT(GPT):
|
||||
def list_models(self):
|
||||
return self.client.models.list()
|
||||
|
||||
def _estimate_messages_bytes(self, messages: list) -> int:
|
||||
import json
|
||||
return len(json.dumps(messages, ensure_ascii=False).encode("utf-8"))
|
||||
|
||||
def _build_merge_messages(self, partials: list) -> list:
|
||||
merge_text = MERGE_PROMPT + "\n\n" + "\n\n---\n\n".join(partials)
|
||||
return [{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": merge_text}]
|
||||
}]
|
||||
|
||||
def _checkpoint_path(self, checkpoint_key: str) -> Path:
|
||||
safe_key = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in checkpoint_key)
|
||||
return self.checkpoint_dir / f"{safe_key}.gpt.checkpoint.json"
|
||||
|
||||
def _build_source_signature(self, source: GPTSource) -> str:
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"temperature": self.temperature,
|
||||
"max_request_bytes": self.max_request_bytes,
|
||||
"title": source.title,
|
||||
"tags": source.tags,
|
||||
"format": source._format,
|
||||
"style": source.style,
|
||||
"extras": source.extras,
|
||||
"video_img_urls": source.video_img_urls or [],
|
||||
"segments": [
|
||||
{
|
||||
"start": getattr(seg, "start", None),
|
||||
"end": getattr(seg, "end", None),
|
||||
"text": getattr(seg, "text", "")
|
||||
}
|
||||
for seg in source.segment
|
||||
],
|
||||
}
|
||||
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True)
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
def _load_checkpoint(self, checkpoint_key: str, source_signature: str) -> dict | None:
|
||||
path = self._checkpoint_path(checkpoint_key)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if data.get("source_signature") != source_signature:
|
||||
path.unlink(missing_ok=True)
|
||||
return None
|
||||
return data
|
||||
except Exception:
|
||||
path.unlink(missing_ok=True)
|
||||
return None
|
||||
|
||||
def _save_checkpoint(self, checkpoint_key: str, source_signature: str, partials: list, phase: str) -> None:
|
||||
path = self._checkpoint_path(checkpoint_key)
|
||||
data = {
|
||||
"version": 1,
|
||||
"source_signature": source_signature,
|
||||
"phase": phase,
|
||||
"partials": partials,
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
tmp_path = path.with_suffix(".tmp")
|
||||
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
tmp_path.replace(path)
|
||||
|
||||
def _clear_checkpoint(self, checkpoint_key: str) -> None:
|
||||
self._checkpoint_path(checkpoint_key).unlink(missing_ok=True)
|
||||
|
||||
@staticmethod
|
||||
def _is_insufficient_quota_error(exc: Exception) -> bool:
|
||||
raw = str(exc)
|
||||
return (
|
||||
"insufficient_user_quota" in raw
|
||||
or "预扣费额度失败" in raw
|
||||
or "insufficient quota" in raw.lower()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_retryable_error(exc: Exception) -> bool:
|
||||
raw = str(exc).lower()
|
||||
retryable_tokens = (
|
||||
"error code: 524",
|
||||
"bad_response_status_code",
|
||||
"timed out",
|
||||
"timeout",
|
||||
"rate limit",
|
||||
"error code: 429",
|
||||
"error code: 500",
|
||||
"error code: 502",
|
||||
"error code: 503",
|
||||
"error code: 504",
|
||||
"apiconnectionerror",
|
||||
"connection error",
|
||||
"service unavailable",
|
||||
)
|
||||
if any(token in raw for token in retryable_tokens):
|
||||
return True
|
||||
|
||||
status = getattr(exc, "status_code", None) or getattr(exc, "status", None)
|
||||
return status in {408, 409, 429, 500, 502, 503, 504, 524}
|
||||
|
||||
def _chat_completion_create(self, messages: list):
|
||||
max_attempts = max(1, int(os.getenv("OPENAI_RETRY_ATTEMPTS", "3")))
|
||||
base_backoff = float(os.getenv("OPENAI_RETRY_BACKOFF_SECONDS", "1.5"))
|
||||
|
||||
last_exc = None
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
return self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=messages,
|
||||
temperature=self.temperature
|
||||
)
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
if attempt == max_attempts - 1 or not self._is_retryable_error(exc):
|
||||
raise
|
||||
sleep_seconds = base_backoff * (2 ** attempt)
|
||||
time.sleep(sleep_seconds)
|
||||
|
||||
if last_exc is not None:
|
||||
raise last_exc
|
||||
raise RuntimeError("chat completion failed without exception")
|
||||
|
||||
def _merge_partials(self, partials: list, checkpoint_key: str | None, source_signature: str | None) -> str:
|
||||
def build_messages(texts, *_args, **_kwargs):
|
||||
return self._build_merge_messages(texts)
|
||||
|
||||
merge_chunker = RequestChunker(
|
||||
lambda *_args, **_kwargs: [],
|
||||
self.max_request_bytes,
|
||||
self._estimate_messages_bytes
|
||||
)
|
||||
|
||||
current_partials = list(partials)
|
||||
while len(current_partials) > 1:
|
||||
groups = merge_chunker.group_texts_by_budget(current_partials, build_messages)
|
||||
new_partials = []
|
||||
for group_idx, group in enumerate(groups):
|
||||
messages = build_messages(group)
|
||||
try:
|
||||
response = self._chat_completion_create(messages)
|
||||
except Exception as exc:
|
||||
if checkpoint_key and source_signature:
|
||||
self._save_checkpoint(checkpoint_key, source_signature, current_partials, "merge")
|
||||
raise
|
||||
|
||||
new_partials.append(response.choices[0].message.content.strip())
|
||||
|
||||
if checkpoint_key and source_signature:
|
||||
remaining_partials = []
|
||||
for remaining_group in groups[group_idx + 1:]:
|
||||
remaining_partials.extend(remaining_group)
|
||||
resumable_partials = new_partials + remaining_partials
|
||||
self._save_checkpoint(checkpoint_key, source_signature, resumable_partials, "merge")
|
||||
|
||||
current_partials = new_partials
|
||||
|
||||
return current_partials[0]
|
||||
|
||||
def summarize(self, source: GPTSource) -> str:
|
||||
self.screenshot = source.screenshot
|
||||
self.link = source.link
|
||||
source.segment = self.ensure_segments_type(source.segment)
|
||||
checkpoint_key = source.checkpoint_key
|
||||
source_signature = self._build_source_signature(source) if checkpoint_key else None
|
||||
|
||||
messages = self.create_messages(
|
||||
source.segment,
|
||||
title=source.title,
|
||||
tags=source.tags,
|
||||
video_img_urls=source.video_img_urls,
|
||||
_format=source._format,
|
||||
style=source.style,
|
||||
extras=source.extras
|
||||
)
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=messages,
|
||||
temperature=0.7
|
||||
)
|
||||
return response.choices[0].message.content.strip()
|
||||
def message_builder(segments, image_urls, **kwargs):
|
||||
return self.create_messages(segments, video_img_urls=image_urls, **kwargs)
|
||||
|
||||
chunker = RequestChunker(message_builder, self.max_request_bytes, self._estimate_messages_bytes)
|
||||
|
||||
try:
|
||||
chunks = chunker.chunk(
|
||||
source.segment,
|
||||
source.video_img_urls or [],
|
||||
title=source.title,
|
||||
tags=source.tags,
|
||||
_format=source._format,
|
||||
style=source.style,
|
||||
extras=source.extras
|
||||
)
|
||||
except ValueError:
|
||||
chunks = chunker.chunk(
|
||||
source.segment,
|
||||
[],
|
||||
title=source.title,
|
||||
tags=source.tags,
|
||||
_format=source._format,
|
||||
style=source.style,
|
||||
extras=source.extras
|
||||
)
|
||||
|
||||
partials = []
|
||||
if checkpoint_key and source_signature:
|
||||
checkpoint = self._load_checkpoint(checkpoint_key, source_signature)
|
||||
if checkpoint and isinstance(checkpoint.get("partials"), list):
|
||||
partials = checkpoint["partials"]
|
||||
|
||||
if len(partials) > len(chunks):
|
||||
partials = []
|
||||
|
||||
for chunk in chunks[len(partials):]:
|
||||
messages = self.create_messages(
|
||||
chunk.segments,
|
||||
title=source.title,
|
||||
tags=source.tags,
|
||||
video_img_urls=chunk.image_urls,
|
||||
_format=source._format,
|
||||
style=source.style,
|
||||
extras=source.extras
|
||||
)
|
||||
try:
|
||||
response = self._chat_completion_create(messages)
|
||||
except Exception as exc:
|
||||
if checkpoint_key and source_signature:
|
||||
self._save_checkpoint(checkpoint_key, source_signature, partials, "summarize")
|
||||
raise
|
||||
|
||||
partials.append(response.choices[0].message.content.strip())
|
||||
if checkpoint_key and source_signature:
|
||||
self._save_checkpoint(checkpoint_key, source_signature, partials, "summarize")
|
||||
|
||||
if len(partials) == 1:
|
||||
if checkpoint_key:
|
||||
self._clear_checkpoint(checkpoint_key)
|
||||
return partials[0]
|
||||
merged = self._merge_partials(partials, checkpoint_key, source_signature)
|
||||
if checkpoint_key:
|
||||
self._clear_checkpoint(checkpoint_key)
|
||||
return merged
|
||||
|
||||
Reference in New Issue
Block a user