Improved video referencing WIP (codex)

This commit is contained in:
2026-09-23 02:08:50 +03:00
parent 1e462b22a9
commit e5e09c00f9
8 changed files with 222 additions and 37 deletions

View File

@@ -324,6 +324,9 @@ Telegram ботом, которому отправили видео).
- Кадры проверяются мультимодальной моделью от общего к частному: сначала - Кадры проверяются мультимодальной моделью от общего к частному: сначала
начало, середина и конец интервала, затем середины оставшихся промежутков. начало, середина и конец интервала, затем середины оставшихся промежутков.
После первого подходящего кадра поиск прекращается. После первого подходящего кадра поиск прекращается.
- Для `ocr` кадр сначала выбирается в режиме `low`, затем `ffmpeg` локально
вырезает нужную область и только этот фрагмент отправляется в `high` для
точного распознавания. Если фрагмент не читается, поиск продолжается.
- Для `vis` выбранный кадр сохраняется в директории `images/`. Для `ocr` в - Для `vis` выбранный кадр сохраняется в директории `images/`. Для `ocr` в
событии сохраняется распознанный текст. событии сохраняется распознанный текст.
- Если подходящий кадр не найден, ссылка сохраняется в корневом списке - Если подходящий кадр не найден, ссылка сохраняется в корневом списке

View File

@@ -2,8 +2,7 @@ import base64
import mimetypes import mimetypes
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Literal, cast from typing import Any, Literal, cast
from pydantic import BaseModel
import httpx2 import httpx2
from openai import OpenAI from openai import OpenAI
@@ -32,6 +31,16 @@ class Agent:
) )
self._model = model self._model = model
@staticmethod
def _raw_content(response: Any) -> str:
"""Read message content without triggering SDK-side schema validation."""
try:
content = response.http_response.json()["choices"][0]["message"]["content"]
except (AttributeError, KeyError, IndexError, TypeError) as error:
raise RuntimeError("Agent returned an invalid response") from error
if not isinstance(content, str):
raise RuntimeError("Agent returned an empty response")
return content
def completion(self, messages: list[AgentMessage], **kwargs) -> str: def completion(self, messages: list[AgentMessage], **kwargs) -> str:
"""Generate a completion for specified messages.""" """Generate a completion for specified messages."""
@@ -43,12 +52,12 @@ class Agent:
"content": m.content "content": m.content
}) })
) )
response = self._client.chat.completions.parse( response = self._client.chat.completions.with_raw_response.parse(
model=self._model, model=self._model,
messages=messages_raw, messages=messages_raw,
**kwargs **kwargs
) )
return response.choices[0].message.content # type: ignore return self._raw_content(response)
def completion_with_images( def completion_with_images(
self, self,
@@ -96,9 +105,9 @@ class Agent:
"content": content, "content": content,
}) })
response = self._client.chat.completions.parse( response = self._client.chat.completions.with_raw_response.parse(
model=self._model, model=self._model,
messages=messages_raw, messages=messages_raw,
**kwargs, **kwargs,
) )
return response.choices[0].message.content # type: ignore return self._raw_content(response)

View File

@@ -242,7 +242,6 @@ CONTEXT
`pending.ids` должны содержать только ID из `present`, которые НЕ были добавлены в `preevents`, потому что их продолжение находится за границей текущего окна. `pending.ids` должны содержать только ID из `present`, которые НЕ были добавлены в `preevents`, потому что их продолжение находится за границей текущего окна.
`pending.summary`: `pending.summary`:
- максимум 160 символов;
- только краткое описание незавершённой мысли; - только краткое описание незавершённой мысли;
- не копируй туда весь исходный текст. - не копируй туда весь исходный текст.

View File

@@ -15,19 +15,24 @@
таблицу, рисунок, объект или демонстрацию; таблицу, рисунок, объект или демонстрацию;
- в `description` кратко и фактически опиши полезное содержимое выбранного - в `description` кратко и фактически опиши полезное содержимое выбранного
кадра; кадра;
- верни `ocr_text: null`. - верни `crop_box: null`.
Для `ocr`: Для `ocr`:
- выбери один кадр, на котором нужный текст виден достаточно полно и чётко; - пока не переписывай текст: выбери один кадр, на котором присутствуют все
- точно перепиши только относящийся к запросу текст или формулу; запрошенные элементы;
- не исправляй и не дополняй распознанное по собственным знаниям; - укажи охватывающую их прямоугольную область как `crop_box` в формате
- сохрани обозначения, индексы, знаки и порядок элементов; `[y_min, x_min, y_max, x_max]`, где координаты нормализованы от 0 до 1000;
- область должна включать весь относящийся к запросу текст или формулу и
небольшой окружающий контекст;
- верни `description: null`. - верни `description: null`.
Не считай совпадением кадр, если содержимое не видно, обрезано, слишком мелкое Для `vis` не считай совпадением кадр, если содержимое не видно, обрезано,
или лишь предположительно соответствует запросу. Не используй текст запроса, слишком мелкое или лишь предположительно соответствует запросу. Для `ocr`
чтобы выдумать отсутствующее содержимое. мелкий текст допустим: сейчас нужно уверенно найти содержащую его область, а
читать её будет следующий этап после увеличения. Не выбирай `ocr`-кадр, если
нужная область отсутствует, обрезана, закрыта или её нельзя уверенно найти.
Не используй текст запроса, чтобы выдумать отсутствующее содержимое.
Если подходят несколько кадров, выбери самый полный и читаемый. Если подходят несколько кадров, выбери самый полный и читаемый.
@@ -37,7 +42,7 @@
"matched": true, "matched": true,
"image_number": 2, "image_number": 2,
"description": "Схема системы управления с входным и выходным векторами.", "description": "Схема системы управления с входным и выходным векторами.",
"ocr_text": null "crop_box": null
} }
Для успешного `ocr`: Для успешного `ocr`:
@@ -46,7 +51,7 @@
"matched": true, "matched": true,
"image_number": 1, "image_number": 1,
"description": null, "description": null,
"ocr_text": "ρ(F_E, F̄) < ε" "crop_box": [180, 240, 720, 810]
} }
Если ни один кадр не подходит: Если ни один кадр не подходит:
@@ -55,7 +60,7 @@
"matched": false, "matched": false,
"image_number": null, "image_number": null,
"description": null, "description": null,
"ocr_text": null "crop_box": null
} }
Не добавляй Markdown, комментарии или текст до и после JSON. Не добавляй Markdown, комментарии или текст до и после JSON.

View File

@@ -0,0 +1,31 @@
Ты выполняешь точное OCR одного фрагмента кадра видеолекции.
В пользовательском сообщении передан `reference` — полный запрос на текст или
формулу. Единственное приложенное изображение — увеличенная область, выбранная
на предыдущем этапе.
- Верни `matched: true` только если видны и читаются все элементы, требуемые в
`reference.text`.
- Перепиши только запрошенное содержимое без исправлений, дополнений и догадок.
- Сохрани черты над символами, индексы, регистр, знаки неравенств, скобки и
порядок элементов.
- Математические выражения записывай в LaTeX, обычный текст оставляй обычным.
- Не подменяй нечёткие обозначения более привычными по смыслу.
- Если хотя бы существенная часть обрезана или неоднозначна, верни
`matched: false`.
Успешный результат:
{
"matched": true,
"ocr_text": "\\rho(\\bar{F}_{э}, \\bar{F}) < \\varepsilon"
}
Если точное распознавание невозможно:
{
"matched": false,
"ocr_text": null
}
Верни ровно один JSON без Markdown, комментариев или текста вокруг него.

View File

@@ -478,7 +478,6 @@ CONTEXT
- эти события не должны быть полностью оформлены в готовые элементы. - эти события не должны быть полностью оформлены в готовые элементы.
`pending.summary`: `pending.summary`:
- максимум 180 символов;
- краткая подсказка для следующей итерации; - краткая подсказка для следующей итерации;
- не является частью итогового документа. - не является частью итогового документа.

View File

@@ -34,7 +34,7 @@ class _FrameResult(_StrictModel):
matched: bool matched: bool
image_number: int | None image_number: int | None
description: str | None description: str | None
ocr_text: str | None crop_box: list[int] | None
@model_validator(mode="after") @model_validator(mode="after")
def validate_match(self) -> "_FrameResult": def validate_match(self) -> "_FrameResult":
@@ -42,12 +42,25 @@ class _FrameResult(_StrictModel):
raise ValueError("A matched result must contain image_number") raise ValueError("A matched result must contain image_number")
if not self.matched and any( if not self.matched and any(
value is not None value is not None
for value in (self.image_number, self.description, self.ocr_text) for value in (self.image_number, self.description, self.crop_box)
): ):
raise ValueError("An unmatched result must not contain image data") raise ValueError("An unmatched result must not contain image data")
return self return self
class _OCRResult(_StrictModel):
matched: bool
ocr_text: str | None
@model_validator(mode="after")
def validate_match(self) -> "_OCRResult":
if self.matched and (self.ocr_text is None or not self.ocr_text.strip()):
raise ValueError("A matched OCR result must contain ocr_text")
if not self.matched and self.ocr_text is not None:
raise ValueError("An unmatched OCR result must not contain ocr_text")
return self
@dataclass(frozen=True) @dataclass(frozen=True)
class _Frame: class _Frame:
timestamp: float timestamp: float
@@ -58,7 +71,9 @@ class ReferenceResolver:
"""Resolve visual references against sampled video frames.""" """Resolve visual references against sampled video frames."""
DEBUG_ID = 0 DEBUG_ID = 0
OCR_DEBUG_ID = 0
MAX_RETRIES = 3 MAX_RETRIES = 3
CROP_PADDING = 30
def __init__( def __init__(
self, self,
@@ -81,6 +96,8 @@ class ReferenceResolver:
self._max_frames = max_frames self._max_frames = max_frames
with open("prompts/reference_resolver.md", "r", encoding="utf-8") as f: with open("prompts/reference_resolver.md", "r", encoding="utf-8") as f:
self._system_prompt = AgentMessage(content=f.read(), role="system") self._system_prompt = AgentMessage(content=f.read(), role="system")
with open("prompts/reference_resolver_ocr.md", "r", encoding="utf-8") as f:
self._ocr_prompt = AgentMessage(content=f.read(), role="system")
def _sampling_timestamps(self, start: float, end: float) -> list[float]: def _sampling_timestamps(self, start: float, end: float) -> list[float]:
start = max(0.0, start) start = max(0.0, start)
@@ -163,13 +180,19 @@ class ReferenceResolver:
if reference.type == "vis": if reference.type == "vis":
if result.description is None or not result.description.strip(): if result.description is None or not result.description.strip():
raise ValueError("A visual match must contain description") raise ValueError("A visual match must contain description")
if result.ocr_text is not None: if result.crop_box is not None:
raise ValueError("A visual match must not contain ocr_text") raise ValueError("A visual match must not contain crop_box")
else: else:
if result.ocr_text is None or not result.ocr_text.strip():
raise ValueError("An OCR match must contain ocr_text")
if result.description is not None: if result.description is not None:
raise ValueError("An OCR match must not contain description") raise ValueError("An OCR match must not contain description")
if result.crop_box is None or len(result.crop_box) != 4:
raise ValueError("An OCR match must contain crop_box")
y_min, x_min, y_max, x_max = result.crop_box
if not (
0 <= y_min < y_max <= 1000
and 0 <= x_min < x_max <= 1000
):
raise ValueError("crop_box must be within 0..1000")
def _check_frames( def _check_frames(
self, self,
@@ -208,7 +231,7 @@ class ReferenceResolver:
response = self._agent.completion_with_images( response = self._agent.completion_with_images(
messages, messages,
[frame.path for frame in frames], [frame.path for frame in frames],
detail="high" if reference.type == "ocr" else "low", detail="low",
response_format=_FrameResult, response_format=_FrameResult,
) )
if debug_dir: if debug_dir:
@@ -228,17 +251,131 @@ class ReferenceResolver:
raise RuntimeError("Agent has failed to provide valid schema too many times") raise RuntimeError("Agent has failed to provide valid schema too many times")
def _crop_frame(
self,
frame: _Frame,
crop_box: list[int],
) -> str | None:
y_min, x_min, y_max, x_max = crop_box
padding = self.CROP_PADDING
y_min = max(0, y_min - padding)
x_min = max(0, x_min - padding)
y_max = min(1000, y_max + padding)
x_max = min(1000, x_max + padding)
width = (x_max - x_min) / 1000
height = (y_max - y_min) / 1000
x = x_min / 1000
y = y_min / 1000
crop_path = str(Path(frame.path).with_name(f"{Path(frame.path).stem}_crop.jpg"))
crop_filter = (
f"crop=trunc(iw*{width:.6f}/2)*2:"
f"trunc(ih*{height:.6f}/2)*2:"
f"iw*{x:.6f}:ih*{y:.6f},"
"scale=1600:1600:force_original_aspect_ratio=decrease:"
"force_divisible_by=2"
)
command = [
"ffmpeg",
"-hide_banner",
"-loglevel", "error",
"-y",
"-ss", f"{frame.timestamp:.3f}",
"-i", self._video_path,
"-frames:v", "1",
"-vf", crop_filter,
"-q:v", "2",
crop_path,
]
try:
subprocess.run(command, check=True)
except subprocess.CalledProcessError:
logging.warning("Failed to crop frame at %.3f", frame.timestamp)
return None
return crop_path if os.path.isfile(crop_path) else None
def _recognize_ocr(
self,
reference: UnresolvedReference,
frame: _Frame,
crop_path: str,
) -> str | None:
request = {
"reference": reference.model_dump(mode="json"),
"timestamp": frame.timestamp,
}
messages = [
self._ocr_prompt,
AgentMessage(
content=json.dumps(request, indent=2, ensure_ascii=False),
role="user",
),
]
debug_dir = None
if os.path.isdir("debug"):
debug_dir = f"debug/ReferenceResolverOCR/{ReferenceResolver.OCR_DEBUG_ID}"
ReferenceResolver.OCR_DEBUG_ID += 1
os.makedirs(debug_dir, exist_ok=True)
with open(f"{debug_dir}/request.txt", "w", encoding="utf-8") as f:
f.write(messages[1].content)
shutil.copyfile(crop_path, f"{debug_dir}/crop.jpg")
retries_left = self.MAX_RETRIES
while retries_left > 0:
retries_left -= 1
response = self._agent.completion_with_images(
messages,
[crop_path],
detail="high",
response_format=_OCRResult,
)
if debug_dir:
with open(
f"{debug_dir}/{retries_left}-retries-left.txt",
"w",
encoding="utf-8",
) as f:
f.write(response)
try:
result = _OCRResult.model_validate_json(response)
if not result.matched:
return None
assert result.ocr_text is not None
return result.ocr_text.strip()
except (ValidationError, ValueError, TypeError):
traceback.print_exc()
raise RuntimeError("Agent has failed to provide valid OCR schema too many times")
def _find_frame( def _find_frame(
self, self,
reference: UnresolvedReference, reference: UnresolvedReference,
frames: list[_Frame], frames: list[_Frame],
) -> tuple[_Frame, _FrameResult] | None: ) -> tuple[_Frame, _FrameResult, str | None] | None:
for level in self._search_levels(len(frames)): for level in self._search_levels(len(frames)):
batch = [frames[index] for index in level] batch = [frames[index] for index in level]
result = self._check_frames(reference, batch) result = self._check_frames(reference, batch)
if result.matched: if not result.matched:
assert result.image_number is not None continue
return batch[result.image_number - 1], result
assert result.image_number is not None
frame = batch[result.image_number - 1]
if reference.type == "vis":
return frame, result, None
assert result.crop_box is not None
crop_path = self._crop_frame(frame, result.crop_box)
if crop_path is None:
continue
ocr_text = self._recognize_ocr(reference, frame, crop_path)
if ocr_text is not None:
return frame, result, ocr_text
logging.info(
"OCR crop at %.3f is unreadable, continuing search",
frame.timestamp,
)
return None return None
def _resolved_event( def _resolved_event(
@@ -247,16 +384,17 @@ class ReferenceResolver:
reference: UnresolvedReference, reference: UnresolvedReference,
frame: _Frame, frame: _Frame,
result: _FrameResult, result: _FrameResult,
ocr_text: str | None,
) -> Event: ) -> Event:
if reference.type == "ocr": if reference.type == "ocr":
assert result.ocr_text is not None assert ocr_text is not None
return Event( return Event(
id=-1, id=-1,
type="ocr", type="ocr",
timestamp=frame.timestamp, timestamp=frame.timestamp,
duration=0.0, duration=0.0,
text=reference.text, text=reference.text,
payload=result.ocr_text, payload=ocr_text,
) )
assert result.description is not None assert result.description is not None
@@ -332,13 +470,14 @@ class ReferenceResolver:
)) ))
continue continue
frame, result = match frame, result, ocr_text = match
resolved.append( resolved.append(
self._resolved_event( self._resolved_event(
reference_index, reference_index,
reference, reference,
frame, frame,
result, result,
ocr_text,
) )
) )

View File

@@ -100,7 +100,7 @@ class _Pending(_StrictModel):
"""An unfinished thought carried over to the next window.""" """An unfinished thought carried over to the next window."""
event_ids: list[int] = Field(min_length=1) event_ids: list[int] = Field(min_length=1)
summary: str = Field(min_length=1, max_length=180) summary: str = Field(min_length=1)
@model_validator(mode="after") @model_validator(mode="after")
def validate_event_ids(self) -> "_Pending": def validate_event_ids(self) -> "_Pending":