🐞 fix: 增加错误之后对已解析段落的缓存功能,再次重试时不再重头开始

解析长视频时,当附件大小过大时不再调用后进行报错,而是将附件进行分批次发送

在每篇笔记开头默认增加地址来源链接,对模糊处可溯源
This commit is contained in:
CyanAutumn
2026-02-12 18:28:11 +08:00
parent 7b45db2f59
commit d9a7b89e7d
67 changed files with 279293 additions and 64 deletions

View File

@@ -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] 标记
- 保持中文输出,专有名词保留英文
- 不要使用代码块包裹输出
'''

View 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

View File

@@ -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