diff --git a/docs/autonomous-quality-gates.md b/docs/autonomous-quality-gates.md index 012b7ec..d7ca572 100644 --- a/docs/autonomous-quality-gates.md +++ b/docs/autonomous-quality-gates.md @@ -58,29 +58,28 @@ commands consume verified state but do not duplicate review logic. - Final export blocks while any current cue remains unaudited. The audit must not claim that deterministic heuristics prove semantic correctness. -### 3. Honest completion QA +### 3. Optional visual diagnostics -- Add a QA report that separates mechanical evidence (current artifact hashes, - duration, non-empty video, rendered frame files) from visual observation. -- A model that cannot receive images may complete mechanical QA, but the result - must say that visual inspection was not performed; it must never report a - visual pass. +- Keep the QA report as an optional diagnostic that separates rendered-frame + evidence from visual observation. +- A model that cannot receive images skips visual QA. This is not a delivery + failure and does not reduce the assessed subtitle-content quality. - A visual attestation, when available, is tied to the current MP4 and rendered frame hashes. Changing the video invalidates the attestation. -- The bundled skill must use the structured QA result and must not infer visual - success from file existence alone. +- The bundled skill must not run visual QA in the default one-shot flow or infer + visual success from file existence alone. - Select up to seven risk frames by boundaries, midpoint, source/target length, - source CPS, and short duration instead of relying on three uniform frames. -- A visual failure requires structured issue codes. Layout failures can be - remediated with the smaller, raised `fansub-compact` preset before reburn. + source CPS, and short duration when a user explicitly requests visual review. +- A visual result is advisory and never automatically selects `fansub-compact` + or triggers export/burn rework. ### 4. Hard delivery gate - `openbbq --json delivery check` is the sole final readiness decision. Any failed gate returns `ready:false` and a non-zero process exit. - Aggregate ASR, fresh segmentation, deterministic translation checks, - full-context audit, exact bilingual ASS events, burn provenance, QA hashes, - and visual attestation without duplicating the underlying domain rules. + full-context audit, exact bilingual ASS events, burn provenance, and a + non-empty burned artifact without duplicating the underlying domain rules. - `status` reports the same delivery result. A successful command or existing MP4 alone never means delivery-ready. @@ -156,7 +155,7 @@ commands consume verified state but do not duplicate review logic. 1. Detect word and segment-level ASR failures with metadata/caption evidence. 2. Require full-coverage, neighbor-bound semantic translation decisions. -3. Add risk-frame visual QA, structured issues, and compact remediation. +3. Keep risk-frame visual QA as an explicit, advisory diagnostic only. 4. Aggregate all facts into a non-zero hard delivery gate and status summary. 5. Keep deterministic checks read-only and every derived artifact hash-bound. 6. Update bundled skills/docs and run static, unit, and real-workspace regressions. diff --git a/docs/glossary-quality-loop.md b/docs/glossary-quality-loop.md new file mode 100644 index 0000000..b7e37be --- /dev/null +++ b/docs/glossary-quality-loop.md @@ -0,0 +1,51 @@ +# Agent-driven glossary quality loop + +## Goal + +A one-shot subtitle request must give the agent enough structured evidence and +safe write operations to discover ASR mistakes from meaning and context, repair +the current source subtitles, and preserve reusable names or mishearings for +future videos. + +Word probability is evidence for ordering only. It is not a correctness gate: +high-confidence words can still be semantically wrong. + +## Workflow contract + +1. `asr check/batch/apply` resolves detector-found issues as before. +2. `glossary audit` pages through every transcript segment and exposes its + surrounding text, word probabilities, overlapping reference caption, and + current glossary matches. The agent reviews meaning rather than accepting a + token only because its probability is high. +3. `asr amend` records a bounded, reasoned phrase correction even when no + detector produced an issue ID. +4. Reusable names, terms, and recurring ASR variants are written atomically with + `glossary apply`. One malformed or colliding change leaves the glossary + untouched. +5. `glossary suggest` mines the ASR-resolved transcript, not the immutable raw + transcript. +6. `segment` reports canonical term matches, alias corrections, and a clear + no-effect signal for the bound glossary. + +## Compatibility and safety + +- Existing glossary and ASR review files remain readable. +- Review and glossary writes are capped at 20 entries per operation. +- Every manual ASR correction requires `segment_id`, `find`, `replacement`, and + a non-empty reason. +- Updating a glossary for a workspace invalidates segmentation and all later + artifacts; it does not trigger translation, export, or burn automatically. + An idempotent no-op update leaves completed stages intact. +- Reference captions and probabilities are advisory evidence. The agent remains + responsible for contextual judgment and must not copy reference text blindly. + +## Acceptance criteria + +- A high-confidence error can be found in a context audit and corrected without + inventing a low-confidence issue ID. +- Adding `{"source": "hot take", "aliases": ["hot tick"]}` corrects the + current segmentation and is available to later workspaces. +- Re-running `glossary suggest` after an ASR replacement cannot re-surface the + replaced raw spelling. +- Segment JSON output distinguishes “glossary bound” from “glossary actually + matched or corrected text.” diff --git a/docs/usage.md b/docs/usage.md index de7b033..a0536da 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -78,7 +78,45 @@ Use accept/replace for words and entities, and keep_first/drop for repeated segments. Every decision requires a reason; phrase replacements also include the exact `find` phrase and `replacement`. When fetch preserved a YouTube VTT, the batch includes overlapping reference text. Repeat until `asr check` returns -`ready: true`; `segment` blocks unresolved or stale decisions. +`ready: true`; `segment` blocks unresolved or stale decisions. This gate only +closes detector-found issues. It does not certify high-confidence words. + +After the detector gate, audit every transcript segment with semantic context: + +```bash +openbbq --json glossary suggest --workspace workspaces/demo +openbbq --json glossary audit --workspace workspaces/demo --offset 0 --limit 20 +``` + +Follow `next_offset` until `remaining` is zero. Audit items include previous and +next text, word probabilities, resolved/raw source, reference captions when +available, and glossary matches. The Agent decides from context; probability and +reference text are advisory evidence. + +For a one-off error with no detector issue id, apply a bounded contextual patch: + +```json +{"amendments":[{"segment_id":12,"find":"hot tick","replacement":"hot take","reason":"The surrounding sentence uses the idiom hot take."}]} +``` + +```bash +openbbq asr amend --workspace workspaces/demo asr-amendments.json +``` + +For reusable names and ASR variants, atomically update the bound glossary: + +```json +{"terms":[{"source":"Andy Matuschak","aliases":["Annie Matushak"],"note":"researcher; confirmed ASR variant"}]} +``` + +```bash +openbbq glossary apply --workspace workspaces/demo glossary-terms.json +openbbq --json segment --workspace workspaces/demo +``` + +Segment output reports `glossary_matched_terms`, +`glossary_aliases_applied`, and `glossary_no_effect`. A bound glossary with no +matches is not treated as proof that terminology was maintained. ## Translate @@ -175,26 +213,22 @@ for an intentional manual draft; an explicitly supplied ASS outside the workspace remains supported. Successful burn also records the final MP4 hash and the exact source-video and ASS hashes. -## Completion QA +## Completion Checks ```bash -openbbq --json qa render --workspace workspaces/demo -openbbq --json qa check --workspace workspaces/demo openbbq --json delivery check --workspace workspaces/demo --to zh ``` -`qa render` defaults to up to seven boundary, midpoint, long-line, high-CPS, -and short-duration risk frames. `mechanical_status: pass` proves that the current non-empty MP4, source video, -ASS, and rendered frame hashes agree. It is not a visual observation. Only -after actually opening every returned frame should a vision-capable reviewer -run `qa attest --result pass|fail --reason ...`. A failure also requires one or -more structured `--issue` values. Without image input, leave -`visual_status: not_performed` and disclose that visual inspection was not -performed. - Final `delivery check` is a hard gate combining ASR, deterministic translation, -full-context semantic review, export/burn freshness, and visual QA. Any failure -returns `ready:false` with a non-zero exit code. +full-context semantic review, exact bilingual ASS content, export/burn +freshness, burn provenance, and a non-empty MP4. Any failure returns +`ready:false` with a non-zero exit code. +Visual layout is not a default gate; non-vision models do not need to inspect +risk frames and are not penalized for skipping them. + +`qa render`, `qa check`, and `qa attest` remain available as optional manual +diagnostics when the user explicitly requests visual review. They do not +automatically select an ASS preset or trigger a reburn. ## ASS Presets @@ -207,8 +241,8 @@ openbbq export --workspace workspaces/demo --to zh --mode bilingual --format ass - `default`: normal 16:9 horizontal video. - `fansub`: more prominent translated line. -- `fansub-compact`: a smaller, raised bilingual stack for lower-third conflicts - and overlap remediation. +- `fansub-compact`: a smaller, raised bilingual stack used only when explicitly + requested; the default flow never auto-selects it from sampled frames. - `mobile`: 9:16 vertical video with a vertical canvas and larger bottom safe area. @@ -278,10 +312,10 @@ openbbq fetch openbbq extract-audio openbbq transcribe openbbq segment -openbbq asr check/batch/apply +openbbq asr check/batch/apply/amend openbbq translate init/batch/apply/check/audit/audit-apply openbbq review -openbbq glossary list/show/new/use/suggest +openbbq glossary list/show/new/use/suggest/audit/apply openbbq export openbbq burn openbbq qa render/check/attest diff --git a/docs/usage.zh-CN.md b/docs/usage.zh-CN.md index cd7f778..1dc43be 100644 --- a/docs/usage.zh-CN.md +++ b/docs/usage.zh-CN.md @@ -73,7 +73,43 @@ openbbq asr apply --workspace workspaces/demo asr-decisions.json 词和实体使用 accept/replace;重复段可用 keep_first/drop。所有决定都必须写理由; 短语 replace 还要提供精确的 `find` 与 `replacement`。fetch 到 YouTube VTT 时,batch 会附带同时间的参考文字。 -重复处理直到 `asr check` 返回 `ready: true`;未决或过期决定会阻止 `segment`。 +重复处理直到 `asr check` 返回 `ready: true`;未决或过期决定会阻止 `segment`。该门禁 +只覆盖检测器发现的问题,不代表高置信词一定正确。 + +检测器门禁通过后,必须结合语义上下文审计全部转写段: + +```bash +openbbq --json glossary suggest --workspace workspaces/demo +openbbq --json glossary audit --workspace workspaces/demo --offset 0 --limit 20 +``` + +沿 `next_offset` 翻页,直到 `remaining` 为 0。每项包含前后段、词级概率、已解析/原始 +source、可用时的参考字幕和 glossary 命中。Agent 根据上下文判断;概率和参考文字只是 +证据。 + +没有检测器 issue id 的一次性错误,用有界 amendment 修正: + +```json +{"amendments":[{"segment_id":12,"find":"hot tick","replacement":"hot take","reason":"结合前后句可知这里是固定表达 hot take。"}]} +``` + +```bash +openbbq asr amend --workspace workspaces/demo asr-amendments.json +``` + +可复用的专名和 ASR 变体用原子 patch 更新已绑定 glossary: + +```json +{"terms":[{"source":"Andy Matuschak","aliases":["Annie Matushak"],"note":"研究者;已确认的 ASR 变体"}]} +``` + +```bash +openbbq glossary apply --workspace workspaces/demo glossary-terms.json +openbbq --json segment --workspace workspaces/demo +``` + +segment 输出会报告 `glossary_matched_terms`、`glossary_aliases_applied` 和 +`glossary_no_effect`。仅仅绑定 glossary 不能证明术语维护真的生效。 ## 翻译 @@ -162,23 +198,19 @@ openbbq --json status --workspace workspaces/demo 外部 ASS 仍然受支持。成功 burn 还会记录最终 MP4,以及所用源视频和 ASS 的精确 内容哈希。 -## 完成 QA +## 完成交付检查 ```bash -openbbq --json qa render --workspace workspaces/demo -openbbq --json qa check --workspace workspaces/demo openbbq --json delivery check --workspace workspaces/demo --to zh ``` -`qa render` 默认选择最多 7 张首尾、中段、长句、高 CPS、短时长风险帧。 -`mechanical_status: pass` 只证明当前非空 MP4、源视频、ASS 和截帧 hash 一致, -不等于看过画面。只有实际打开并检查返回的每一张 frame 后,有视觉输入能力的审核者 -才能运行 `qa attest --result pass|fail --reason ...`。失败必须额外用一个或多个 -`--issue` 记录结构化问题。没有图像输入能力时必须保留 -`visual_status: not_performed`,并明确说明未执行视觉检查。 - 最终 `delivery check` 是硬门禁:它综合 ASR、翻译机械检查、全覆盖上下文审校、 -export/burn freshness 与视觉 QA。任一失败都会返回 `ready:false` 和非零退出码。 +双语 ASS 内容、export/burn freshness、烧录 provenance 与非空 MP4。任一失败都会返回 +`ready:false` 和非零退出码。视觉排版不属于默认门禁;非多模态模型不需要查看风险帧, +也不会因此被判定失败。 + +`qa render`、`qa check` 和 `qa attest` 仍保留为用户明确要求时的可选人工诊断, +但不会自动切换 ASS 预设或触发重烧录。 ## ASS 预设 @@ -191,7 +223,8 @@ openbbq export --workspace workspaces/demo --to zh --mode bilingual --format ass - `default`:常规 16:9 横屏视频。 - `fansub`:译文行更醒目。 -- `fansub-compact`:更小且上移的双语堆叠,用于下三分之一冲突或遮挡修复。 +- `fansub-compact`:更小且上移的双语堆叠,仅在用户明确指定时使用;默认流程不会 + 根据抽帧自动选择它。 - `mobile`:面向 9:16 竖屏视频,使用竖屏画布和更大的底部安全区。 `mobile` 只改变渲染样式。目标语行容量由 `translate init` 的覆盖参数控制;如果仍 @@ -256,10 +289,10 @@ openbbq fetch openbbq extract-audio openbbq transcribe openbbq segment -openbbq asr check/batch/apply +openbbq asr check/batch/apply/amend openbbq translate init/batch/apply/check/audit/audit-apply openbbq review -openbbq glossary list/show/new/use/suggest +openbbq glossary list/show/new/use/suggest/audit/apply openbbq export openbbq burn openbbq qa render/check/attest diff --git a/pyproject.toml b/pyproject.toml index d4f97b8..de4b15f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openbbq" -version = "0.0.7" +version = "0.0.8" description = "Agent-friendly CLI for video translation and subtitle production." readme = "README.md" authors = [ diff --git a/src/openbbq/__init__.py b/src/openbbq/__init__.py index 6526deb..a73339b 100644 --- a/src/openbbq/__init__.py +++ b/src/openbbq/__init__.py @@ -1 +1 @@ -__version__ = "0.0.7" +__version__ = "0.0.8" diff --git a/src/openbbq/cli/commands/asr.py b/src/openbbq/cli/commands/asr.py index 08fdebf..dc895be 100644 --- a/src/openbbq/cli/commands/asr.py +++ b/src/openbbq/cli/commands/asr.py @@ -1,5 +1,6 @@ from __future__ import annotations +import shlex from pathlib import Path from typing import Annotated, Literal @@ -10,7 +11,7 @@ from ...core import asr_review as reviewlib from ...core import workspace as ws from ...errors import OpenBBQError -from ...schemas import OpenBBQModel, Stage +from ...schemas import AsrReview, OpenBBQModel, Stage from ..output import Output from ..results import Result @@ -163,6 +164,19 @@ def render(self) -> str: ) +class AsrAmendResult(Result): + artifact: str + applied: int + amendment_ids: list[str] + ready: bool + + def render(self) -> str: + return ( + f"[green]✓[/] contextual ASR corrections applied: {self.applied}\n" + f" artifact: {self.artifact}" + ) + + def _load(workspace: str | None): path = ws.resolve_workspace(workspace) manifest = ws.read_manifest(path) @@ -185,6 +199,17 @@ def _load(workspace: str | None): return path, transcript, review, reference_texts, captions +def _write_review_and_invalidate(path: Path, review: AsrReview) -> Path: + artifact = ws.write_asr_review(path, review) + manifest = ws.read_manifest(path) + transcribe_state = manifest.stages.get(Stage.TRANSCRIBE) + if transcribe_state is not None: + # Review data is an input to segmentation. Re-recording transcribe + # invalidates later artifacts without changing transcript.json itself. + ws.record_stage(path, manifest, Stage.TRANSCRIBE, transcribe_state) + return artifact + + @app.command() def check( ctx: typer.Context, @@ -194,12 +219,15 @@ def check( ] = None, max_prob: Annotated[ float, - typer.Option("--max-prob", help="review word occurrences below this probability"), + typer.Option( + "--max-prob", help="review word occurrences below this probability" + ), ] = reviewlib.DEFAULT_MAX_PROB, ) -> None: """Report whether every ASR word issue and segment anomaly has a decision.""" output: Output = ctx.obj - _, transcript, review, reference_texts, captions = _load(workspace) + path, transcript, review, reference_texts, captions = _load(workspace) + workspace_arg = shlex.quote(str(path)) report = reviewlib.check( transcript, review, @@ -220,9 +248,9 @@ def check( ready=report.ready, reference_caption_available=bool(captions), next=( - "openbbq segment" + f"openbbq glossary suggest --workspace {workspace_arg}" if report.ready - else "openbbq asr batch --limit 20" + else f"openbbq asr batch --workspace {workspace_arg} --limit 20" ), ) ) @@ -235,15 +263,21 @@ def batch( str | None, typer.Option("--workspace", "-w", help="workspace dir (default: cwd upward)"), ] = None, - offset: Annotated[int, typer.Option("--offset", help="zero-based result offset")] = 0, - limit: Annotated[int, typer.Option("--limit", help="maximum issues to return")] = 20, + offset: Annotated[ + int, typer.Option("--offset", help="zero-based result offset") + ] = 0, + limit: Annotated[ + int, typer.Option("--limit", help="maximum issues to return") + ] = 20, only_unresolved: Annotated[ bool, typer.Option("--only-unresolved/--all", help="exclude already resolved issues"), ] = True, max_prob: Annotated[ float, - typer.Option("--max-prob", help="review word occurrences below this probability"), + typer.Option( + "--max-prob", help="review word occurrences below this probability" + ), ] = reviewlib.DEFAULT_MAX_PROB, ) -> None: """Read a bounded batch of ASR anomalies followed by word occurrences.""" @@ -255,7 +289,8 @@ def batch( fix=f"use --offset >= 0 and --limit from 1 to {reviewlib.MAX_DECISION_BATCH}", ) output: Output = ctx.obj - _, transcript, review, reference_texts, captions = _load(workspace) + path, transcript, review, reference_texts, captions = _load(workspace) + workspace_arg = shlex.quote(str(path)) report = reviewlib.check( transcript, review, @@ -269,7 +304,9 @@ def batch( if not only_unresolved or issue.id not in resolved ] selected = candidates[offset : offset + limit] - next_offset = offset + len(selected) if offset + len(selected) < len(candidates) else None + next_offset = ( + offset + len(selected) if offset + len(selected) < len(candidates) else None + ) output.emit( AsrBatchResult( transcript_hash=report.transcript_hash, @@ -288,9 +325,10 @@ def batch( stale=report.stale, reference_caption_available=bool(captions), next=( - f"openbbq asr batch --offset {next_offset} --limit {limit}" + f"openbbq asr batch --workspace {workspace_arg} " + f"--offset {next_offset} --limit {limit}" if next_offset is not None - else "openbbq asr apply " + else f"openbbq asr apply --workspace {workspace_arg} " ), ) ) @@ -309,7 +347,9 @@ def apply( ] = None, max_prob: Annotated[ float, - typer.Option("--max-prob", help="review word occurrences below this probability"), + typer.Option( + "--max-prob", help="review word occurrences below this probability" + ), ] = reviewlib.DEFAULT_MAX_PROB, ) -> None: """Merge explicit accept/replace/drop/keep_first ASR decisions.""" @@ -332,19 +372,8 @@ def apply( max_prob=max_prob, reference_texts=reference_texts, ) - artifact = ws.write_asr_review(path, merged) - manifest = ws.read_manifest(path) - transcribe_state = manifest.stages.get(Stage.TRANSCRIBE) - if transcribe_state is not None: - # ASR decisions are inputs to segmentation. Re-recording the completed - # transcribe state invalidates every downstream artifact without - # pretending transcript.json itself changed. - ws.record_stage( - path, - manifest, - Stage.TRANSCRIBE, - transcribe_state, - ) + artifact = _write_review_and_invalidate(path, merged) + workspace_arg = shlex.quote(str(path)) report = reviewlib.check( transcript, merged, @@ -360,9 +389,65 @@ def apply( unresolved=len(report.unresolved_ids), ready=report.ready, next=( - "openbbq segment" + f"openbbq glossary suggest --workspace {workspace_arg}" + if report.ready + else f"openbbq asr batch --workspace {workspace_arg} " + "--limit 20 --only-unresolved" + ), + ) + ) + + +@app.command() +def amend( + ctx: typer.Context, + amendments: Annotated[ + str, + typer.Argument(help="JSON file with contextual ASR amendments"), + ], + workspace: Annotated[ + str | None, + typer.Option("--workspace", "-w", help="workspace dir (default: cwd upward)"), + ] = None, +) -> None: + """Apply agent-found phrase corrections without a detector issue id.""" + + output: Output = ctx.obj + path, transcript, review, reference_texts, _ = _load(workspace) + amendments_path = Path(amendments).expanduser() + try: + raw = amendments_path.read_text(encoding="utf-8") + except OSError as error: + raise OpenBBQError( + "asr_amendments_not_found", + path=str(amendments_path), + fix="write the amendment JSON and try again", + ) from error + parsed = reviewlib.parse_amendments(raw) + merged, amendment_ids = reviewlib.merge_amendments( + transcript, + review, + parsed, + ) + artifact = _write_review_and_invalidate(path, merged) + workspace_arg = shlex.quote(str(path)) + report = reviewlib.check( + transcript, + merged, + max_prob=merged.max_prob, + reference_texts=reference_texts, + ) + output.emit( + AsrAmendResult( + artifact=str(artifact.relative_to(path)), + applied=len(parsed), + amendment_ids=amendment_ids, + ready=report.ready, + next=( + f"openbbq glossary suggest --workspace {workspace_arg}" if report.ready - else "openbbq asr batch --limit 20 --only-unresolved" + else f"openbbq asr batch --workspace {workspace_arg} " + "--limit 20 --only-unresolved" ), ) ) diff --git a/src/openbbq/cli/commands/burn.py b/src/openbbq/cli/commands/burn.py index 257ca1d..3159902 100644 --- a/src/openbbq/cli/commands/burn.py +++ b/src/openbbq/cli/commands/burn.py @@ -275,6 +275,6 @@ def cb(progress: media.BurnProgress) -> None: duration_s=outcome.duration_s, elapsed_s=round(time.monotonic() - started, 2), ffmpeg=outcome.ffmpeg, - next="openbbq qa render", + next="openbbq delivery check", ) ) diff --git a/src/openbbq/cli/commands/glossary.py b/src/openbbq/cli/commands/glossary.py index 30d5447..6cf959e 100644 --- a/src/openbbq/cli/commands/glossary.py +++ b/src/openbbq/cli/commands/glossary.py @@ -1,11 +1,14 @@ from __future__ import annotations +import shlex +from pathlib import Path from typing import Annotated import typer from rich.console import RenderableType from rich.table import Table +from ...core import asr_review as asr_reviewlib from ...core import glossary as glossarylib from ...core import workspace as ws from ...errors import OpenBBQError @@ -40,7 +43,9 @@ def of(cls) -> GlossaryListResult: def render(self) -> RenderableType: if not self.glossaries: - return "[dim]no glossaries — create one with `openbbq glossary new `[/]" + return ( + "[dim]no glossaries — create one with `openbbq glossary new `[/]" + ) table = Table(show_header=True, header_style="bold", box=None) table.add_column("name") table.add_column("terms", justify="right") @@ -115,6 +120,78 @@ def render(self) -> RenderableType: return table +# --- audit -------------------------------------------------------------------- +class AuditWordReport(OpenBBQModel): + index: int + word: str + prob: float | None = None + + +class GlossaryAuditItem(OpenBBQModel): + segment_id: int + start: float + end: float + source: str + raw_source: str | None = None + after_glossary: str | None = None + previous: str | None = None + next_segment: str | None = None + words: list[AuditWordReport] + min_prob: float | None = None + reference_caption: str | None = None + glossary_terms: list[str] + + +class GlossaryAuditResult(Result): + transcript_hash: str + glossary: str | None = None + offset: int + total: int + items: list[GlossaryAuditItem] + next_offset: int | None = None + remaining: int + asr_ready: bool + asr_review_stale: bool + reference_caption_available: bool + + def render(self) -> RenderableType: + table = Table(show_header=True, header_style="bold", box=None) + table.add_column("segment") + table.add_column("time", justify="right") + table.add_column("min prob", justify="right") + table.add_column("source") + table.add_column("reference", style="dim") + for item in self.items: + table.add_row( + str(item.segment_id), + f"{item.start:.2f}s", + "—" if item.min_prob is None else f"{item.min_prob:.2f}", + item.source, + item.reference_caption or "—", + ) + return table + + +# --- apply -------------------------------------------------------------------- +class GlossaryApplyResult(Result): + name: str + path: str + added: list[str] + updated: list[str] + unchanged: list[str] + aliases_added: int + total_terms: int + workspace_invalidated: str | None = None + + def render(self) -> str: + changed = len(self.added) + len(self.updated) + return ( + f"[green]✓[/] glossary updated: {self.name}\n" + f" {changed} changed · {self.aliases_added} aliases added · " + f"{self.total_terms} total terms" + ) + + @app.command(name="list") def list_glossaries(ctx: typer.Context) -> None: """List glossaries in the library with term counts.""" @@ -139,7 +216,9 @@ def new( name: Annotated[str, typer.Argument(help="glossary name, e.g. frieren")], context: Annotated[ str | None, - typer.Option("--context", help="series/topic background that scopes this glossary"), + typer.Option( + "--context", help="series/topic background that scopes this glossary" + ), ] = None, ) -> None: """Scaffold a new glossary (optionally declaring its scope via --context).""" @@ -178,7 +257,10 @@ def suggest( typer.Option("--glossary", help="exclude terms already known to this glossary"), ] = None, max_prob: Annotated[ - float, typer.Option("--max-prob", help="only surface words below this avg probability") + float, + typer.Option( + "--max-prob", help="only surface words below this avg probability" + ), ] = 0.6, min_count: Annotated[ int, typer.Option("--min-count", help="minimum occurrences to surface") @@ -189,9 +271,23 @@ def suggest( output: Output = ctx.obj path = ws.resolve_workspace(workspace) manifest = ws.read_manifest(path) - tpath = ws.require_artifact(path, manifest, Stage.TRANSCRIBE, fix="openbbq transcribe") + tpath = ws.require_artifact( + path, manifest, Stage.TRANSCRIBE, fix="openbbq transcribe" + ) transcript = ws.read_transcript(tpath) + review = ws.read_asr_review_optional(path) + reference_texts = [ + text + for text in (manifest.source.title, manifest.source.author) + if manifest.source.type == "url" and text + ] + transcript = asr_reviewlib.resolved_transcript( + transcript, + review, + reference_texts=reference_texts, + ) + gloss = glossarylib.load_optional(glossary or manifest.glossary) known = glossarylib.known_forms(gloss) if gloss is not None else set() candidates = glossarylib.suggest_candidates( @@ -207,6 +303,218 @@ def suggest( example=c.example, ) for c in candidates - ] + ], + next=( + "openbbq glossary audit --workspace " + f"{shlex.quote(str(path))} --limit 20" + ), + ) + ) + + +@app.command() +def audit( + ctx: typer.Context, + workspace: Annotated[ + str | None, + typer.Option("--workspace", "-w", help="workspace dir (default: cwd upward)"), + ] = None, + glossary: Annotated[ + str | None, + typer.Option("--glossary", help="glossary name (overrides manifest binding)"), + ] = None, + offset: Annotated[ + int, typer.Option("--offset", help="zero-based segment offset") + ] = 0, + limit: Annotated[ + int, typer.Option("--limit", help="segments returned per page") + ] = 20, +) -> None: + """Read every resolved transcript segment with evidence for agent judgment.""" + + if offset < 0 or not 1 <= limit <= 20: + raise OpenBBQError( + "invalid_batch", + offset=offset, + limit=limit, + fix="use --offset >= 0 and --limit from 1 to 20", + ) + output: Output = ctx.obj + path = ws.resolve_workspace(workspace) + manifest = ws.read_manifest(path) + tpath = ws.require_artifact( + path, manifest, Stage.TRANSCRIBE, fix="openbbq transcribe" + ) + raw_transcript = ws.read_transcript(tpath) + review = ws.read_asr_review_optional(path) + reference_texts = [ + text + for text in (manifest.source.title, manifest.source.author) + if manifest.source.type == "url" and text + ] + report = asr_reviewlib.check( + raw_transcript, + review, + reference_texts=reference_texts, + ) + resolved = asr_reviewlib.resolved_transcript( + raw_transcript, + review, + reference_texts=reference_texts, + ) + caption_source = ws.read_reference_caption_optional(path) + captions = ( + asr_reviewlib.parse_reference_captions(caption_source) + if caption_source is not None + else [] + ) + glossary_name = glossary or manifest.glossary + gloss = glossarylib.load_optional(glossary_name) + raw_by_id = {segment.id: segment for segment in raw_transcript.segments} + selected = resolved.segments[offset : offset + limit] + workspace_arg = shlex.quote(str(path)) + glossary_arg = ( + f" --glossary {shlex.quote(glossary)}" if glossary is not None else "" + ) + items: list[GlossaryAuditItem] = [] + for index, segment in enumerate(selected, start=offset): + raw_segment = raw_by_id.get(segment.id) + words = raw_segment.words if raw_segment is not None else None + probabilities = [word.prob for word in words or [] if word.prob is not None] + glossary_source = glossarylib.corrector(gloss)(segment.text) + items.append( + GlossaryAuditItem( + segment_id=segment.id, + start=segment.start, + end=segment.end, + source=segment.text, + raw_source=( + raw_segment.text + if raw_segment is not None and raw_segment.text != segment.text + else None + ), + after_glossary=( + glossary_source if glossary_source != segment.text else None + ), + previous=(resolved.segments[index - 1].text if index > 0 else None), + next_segment=( + resolved.segments[index + 1].text + if index + 1 < len(resolved.segments) + else None + ), + words=[ + AuditWordReport(index=word_index, word=word.word, prob=word.prob) + for word_index, word in enumerate(words or []) + ], + min_prob=min(probabilities) if probabilities else None, + reference_caption=asr_reviewlib.reference_caption_text( + captions, + start=segment.start, + end=segment.end, + ), + glossary_terms=glossarylib.matched_terms(gloss, segment.text), + ) + ) + next_offset = ( + offset + len(selected) + if offset + len(selected) < len(resolved.segments) + else None + ) + output.emit( + GlossaryAuditResult( + transcript_hash=report.transcript_hash, + glossary=glossary_name, + offset=offset, + total=len(resolved.segments), + items=items, + next_offset=next_offset, + remaining=max(len(resolved.segments) - offset - len(selected), 0), + asr_ready=report.ready, + asr_review_stale=report.stale, + reference_caption_available=bool(captions), + next=( + f"openbbq glossary audit --workspace {workspace_arg}{glossary_arg} " + f"--offset {next_offset} --limit {limit}" + if next_offset is not None + else f"openbbq glossary apply --workspace {workspace_arg} " + ), + ) + ) + + +@app.command(name="apply") +def apply_patch( + ctx: typer.Context, + changes: Annotated[ + str, + typer.Argument(help="JSON file with a terms array to add or update"), + ], + workspace: Annotated[ + str | None, + typer.Option( + "--workspace", "-w", help="workspace whose bound glossary is updated" + ), + ] = None, + glossary: Annotated[ + str | None, + typer.Option("--glossary", help="glossary name (default: workspace binding)"), + ] = None, +) -> None: + """Atomically add or update up to 20 curated glossary terms.""" + + output: Output = ctx.obj + path = ( + ws.resolve_workspace(workspace) + if workspace is not None or glossary is None + else None + ) + manifest = ws.read_manifest(path) if path is not None else None + name = glossary or (manifest.glossary if manifest is not None else None) + if not name: + raise OpenBBQError( + "glossary_not_bound", + fix="pass --glossary or bind one with `openbbq glossary use`", + ) + existing = glossarylib.load(name) + changes_path = Path(changes).expanduser() + try: + raw = changes_path.read_text(encoding="utf-8") + except OSError as error: + raise OpenBBQError( + "glossary_patch_not_found", + path=str(changes_path), + fix="write the glossary patch JSON and try again", + ) from error + patches = glossarylib.parse_term_patch(raw) + updated, report = glossarylib.upsert_terms(existing, patches) + changed = bool(report.added or report.updated) + saved = glossarylib.save(updated) if changed else glossarylib.glossary_path(name) + + invalidated: str | None = None + if ( + changed + and path is not None + and manifest is not None + and manifest.glossary == name + ): + transcribe_state = manifest.stages.get(Stage.TRANSCRIBE) + if transcribe_state is not None: + ws.record_stage(path, manifest, Stage.TRANSCRIBE, transcribe_state) + invalidated = str(path) + output.emit( + GlossaryApplyResult( + name=name, + path=str(saved), + added=list(report.added), + updated=list(report.updated), + unchanged=list(report.unchanged), + aliases_added=report.aliases_added, + total_terms=len(updated.terms), + workspace_invalidated=invalidated, + next=( + f"openbbq segment --workspace {shlex.quote(str(path))}" + if invalidated is not None + else None + ), ) ) diff --git a/src/openbbq/cli/commands/qa.py b/src/openbbq/cli/commands/qa.py index 9b3b305..06edbf0 100644 --- a/src/openbbq/cli/commands/qa.py +++ b/src/openbbq/cli/commands/qa.py @@ -65,19 +65,9 @@ def _result( "--result pass|fail --reason " ) elif visual_status == "fail": - issue_codes = {issue.code for issue in report.visual_issues} - if QaVisualIssueCode.CONTENT_ERROR in issue_codes: - next_step = ( - "fix ASR/translation content, rerun translation audit, export, burn, " - "and qa render" - ) - elif issue_codes: - next_step = ( - "openbbq export --format ass --mode bilingual " - "--ass-preset fansub-compact; then rerun burn and qa render" - ) - else: - next_step = "fix the reported visual issue, then rerun export, burn, and qa" + # Visual QA is advisory. Keep the observation and structured issues, but + # do not turn them into an automatic preset switch or reburn loop. + next_step = None else: next_step = None return QaResult( diff --git a/src/openbbq/cli/commands/segment.py b/src/openbbq/cli/commands/segment.py index a0f5058..01e3e16 100644 --- a/src/openbbq/cli/commands/segment.py +++ b/src/openbbq/cli/commands/segment.py @@ -10,7 +10,7 @@ from ...core import glossary as glossarylib from ...core import segment as seg from ...core import workspace as ws -from ...schemas import Cues, SegmentParams, Stage, StageState, StageStatus +from ...schemas import Cues, OpenBBQModel, SegmentParams, Stage, StageState, StageStatus from ...errors import OpenBBQError from ..output import Output from ..results import Result @@ -19,6 +19,12 @@ # --- contract layer ----------------------------------------------------------- +class GlossaryAliasApplicationReport(OpenBBQModel): + source: str + alias: str + count: int + + class SegmentResult(Result): artifact: str # relative to the workspace cues: int # number of cues produced @@ -26,13 +32,25 @@ class SegmentResult(Result): over_cap: int # cues that couldn't fit the line budget source_lang: str generic_profile: bool # True when the latin fallback was used (no language profile) + glossary: str | None = None + glossary_matched_terms: list[str] = [] + glossary_aliases_applied: list[GlossaryAliasApplicationReport] = [] + glossary_no_effect: bool | None = None elapsed_s: float def render(self) -> str: + glossary = "" + if self.glossary is not None: + glossary = ( + f"\n glossary {self.glossary}: " + f"{len(self.glossary_matched_terms)} term(s) matched · " + f"{sum(item.count for item in self.glossary_aliases_applied)} alias correction(s)" + ) return ( f"[green]✓[/] segmented: {self.artifact}\n" f" {self.cues} cues · {self.source_lang} · " f"{self.over_cps} over-CPS · {self.over_cap} over-width" + f"{glossary}" ) @@ -49,7 +67,9 @@ def segment( ] = None, glossary: Annotated[ str | None, - typer.Option("--glossary", help="glossary name (overrides the manifest binding)"), + typer.Option( + "--glossary", help="glossary name (overrides the manifest binding)" + ), ] = None, max_cps: Annotated[ float | None, typer.Option("--max-cps", help="max characters per second") @@ -71,7 +91,9 @@ def segment( ] = None, pause_threshold: Annotated[ float | None, - typer.Option("--pause-threshold", help="natural-pause split threshold, seconds"), + typer.Option( + "--pause-threshold", help="natural-pause split threshold, seconds" + ), ] = None, ) -> None: """Split the transcript into subtitle cues (source side, deterministic).""" @@ -116,8 +138,10 @@ def segment( pause_threshold=pause_threshold, ) - gloss = glossarylib.load_optional(glossary or manifest.glossary) - correct = asr_reviewlib.corrector(asr_review, glossarylib.corrector(gloss)) + glossary_name = glossary or manifest.glossary + gloss = glossarylib.load_optional(glossary_name) + glossary_tracker = glossarylib.CorrectionTracker(gloss) + correct = asr_reviewlib.corrector(asr_review, glossary_tracker) reviewed_transcript = asr_reviewlib.apply_segment_decisions( transcript, asr_review, @@ -172,6 +196,19 @@ def segment( over_cap=outcome.over_cap, source_lang=source_lang, generic_profile=generic, + glossary=glossary_name, + glossary_matched_terms=sorted(glossary_tracker.matched_terms), + glossary_aliases_applied=[ + GlossaryAliasApplicationReport( + source=item.source, + alias=item.alias, + count=item.count, + ) + for item in glossary_tracker.alias_applications + ], + glossary_no_effect=( + not glossary_tracker.matched_terms if gloss is not None else None + ), elapsed_s=round(time.monotonic() - started, 2), next=( "openbbq translate init " diff --git a/src/openbbq/cli/delivery.py b/src/openbbq/cli/delivery.py index 6c62a84..74ccfef 100644 --- a/src/openbbq/cli/delivery.py +++ b/src/openbbq/cli/delivery.py @@ -12,7 +12,6 @@ from openbbq.core import asr_review as asrlib from openbbq.core import export as exportlib -from openbbq.core import qa as qalib from openbbq.core import translate as translatelib from openbbq.core import translation_audit as auditlib from openbbq.core import workspace as ws @@ -150,7 +149,6 @@ def assess_delivery( "export": False, "burn": False, "qa_mechanical": False, - "qa_visual": False, } resolved_lang, lang_issue = _resolved_lang(path, lang) if lang_issue is not None: @@ -406,69 +404,19 @@ def assess_delivery( ) else: gates["burn"] = True - - qa_invalid = False - try: - report = ws.read_qa_optional(path) - except OpenBBQError as error: - qa_invalid = True - report = None - _read_error( - issues, - gate="qa_mechanical", - error=error, - fix="remove the invalid QA report and rerun openbbq qa render", - ) - if report is None and not qa_invalid: - issues.append( - DeliveryIssue( - code="qa_not_found", - gate="qa_mechanical", - detail="no rendered-frame QA report exists", - fix="openbbq qa render", - ) - ) - elif report is not None: - artifact = manifest.stages[Stage.BURN].artifact - assert artifact is not None - qa = qalib.assess( - report, - artifact=artifact, - artifact_path=burn_path, - workspace=path, - ) - if qa.mechanical_status == "pass" and gates["burn"]: + try: + nonempty = burn_path.stat().st_size > 0 + except OSError: + nonempty = False + if nonempty: gates["qa_mechanical"] = True else: issues.append( DeliveryIssue( - code="qa_mechanical_stale", + code="invalid_burn_output", gate="qa_mechanical", - detail="QA evidence is stale: " + ", ".join(qa.issues), - fix="openbbq qa render", - ) - ) - if gates["qa_mechanical"] and report.visual_status == "pass": - gates["qa_visual"] = True - elif gates["qa_mechanical"] and report.visual_status == "fail": - issues.append( - DeliveryIssue( - code="qa_visual_failed", - gate="qa_visual", - detail=report.visual_reason or "visual inspection failed", - fix="fix the reported layout issue, then rerun export, burn, and qa", - ) - ) - elif gates["qa_mechanical"]: - issues.append( - DeliveryIssue( - code="qa_visual_not_performed", - gate="qa_visual", - detail="rendered frames have not been visually inspected", - fix=( - "inspect every frame, then run openbbq qa attest " - "--result pass --reason " - ), + detail="burned video is empty or unreadable", + fix="openbbq burn", ) ) diff --git a/src/openbbq/core/asr_review.py b/src/openbbq/core/asr_review.py index 8cf5931..8b736b6 100644 --- a/src/openbbq/core/asr_review.py +++ b/src/openbbq/core/asr_review.py @@ -7,6 +7,7 @@ from __future__ import annotations import hashlib +import html import json import re from collections.abc import Callable, Mapping @@ -17,7 +18,14 @@ from pydantic import ValidationError from openbbq.errors import OpenBBQError -from openbbq.schemas import AsrDecision, AsrReview, Segment, Transcript, Word +from openbbq.schemas import ( + AsrAmendment, + AsrDecision, + AsrReview, + Segment, + Transcript, + Word, +) DEFAULT_MAX_PROB = 0.5 MAX_WORDS_PER_SECOND = 9.0 @@ -169,6 +177,31 @@ def _vtt_seconds(value: str) -> float: return int(hours) * 3600 + int(minutes) * 60 + float(seconds) +def _merge_rolling_texts(values: list[str]) -> str: + """Collapse YouTube's cumulative/rolling caption lines by token overlap.""" + + merged: list[str] = [] + for value in values: + tokens = value.split() + if not tokens: + continue + current = " ".join(merged) + if value in current: + continue + if current and current in value: + merged = tokens + continue + overlap = 0 + for size in range(min(len(merged), len(tokens)), 0, -1): + if [token.casefold() for token in merged[-size:]] == [ + token.casefold() for token in tokens[:size] + ]: + overlap = size + break + merged.extend(tokens[overlap:]) + return " ".join(merged) + + def parse_reference_captions(text: str) -> list[ReferenceCaption]: """Parse the timing/text subset shared by WebVTT subtitle variants.""" @@ -183,11 +216,11 @@ def parse_reference_captions(text: str) -> list[ReferenceCaption]: index += 1 body: list[str] = [] while index < len(lines) and lines[index].strip(): - cleaned = _VTT_TAG_RE.sub("", lines[index]).strip() - if cleaned and (not body or cleaned != body[-1]): + cleaned = html.unescape(_VTT_TAG_RE.sub("", lines[index])).strip() + if cleaned: body.append(cleaned) index += 1 - caption_text = " ".join(body).strip() + caption_text = _merge_rolling_texts(body) if caption_text: cues.append( ReferenceCaption( @@ -210,7 +243,8 @@ def reference_caption_text( for caption in captions if caption.start < end and caption.end > start ] - return " ".join(dict.fromkeys(overlapping)) if overlapping else None + merged = _merge_rolling_texts(overlapping) + return merged or None def _metadata_entity_anomalies( @@ -254,9 +288,7 @@ def _metadata_entity_anomalies( ).ratio() if not 0.6 <= similarity < 0.95: continue - find = segment.text[ - observed_first.start() : observed_last.end() - ] + find = segment.text[observed_first.start() : observed_last.end()] replacement = reference[ expected_first.start() : expected_last.end() ] @@ -292,9 +324,7 @@ def _metadata_entity_anomalies( def _anomaly_context( transcript: Transcript, first_index: int, last_index: int ) -> tuple[str | None, str | None]: - previous = ( - transcript.segments[first_index - 1].text if first_index > 0 else None - ) + previous = transcript.segments[first_index - 1].text if first_index > 0 else None next_text = ( transcript.segments[last_index + 1].text if last_index + 1 < len(transcript.segments) @@ -373,9 +403,7 @@ def extract_anomalies( words_per_second=round(rate, 3), ) ) - anomalies.extend( - _metadata_entity_anomalies(transcript, tuple(reference_texts)) - ) + anomalies.extend(_metadata_entity_anomalies(transcript, tuple(reference_texts))) return sorted(anomalies, key=lambda issue: (issue.start, issue.id)) @@ -457,6 +485,42 @@ def parse_decisions(text: str) -> dict[str, AsrDecision]: return decisions +def parse_amendments(text: str) -> list[AsrAmendment]: + """Parse a bounded contextual-audit patch document. + + The wrapper keeps the format self-describing and leaves room for future + audit metadata without changing the list of amendment fields. + """ + + try: + raw = json.loads(text) + except json.JSONDecodeError as error: + raise OpenBBQError( + "asr_amendments_invalid", + detail="expected a JSON object with a non-empty amendments array", + ) from error + values = raw.get("amendments") if isinstance(raw, dict) else None + if not isinstance(values, list) or not values: + raise OpenBBQError( + "asr_amendments_invalid", + detail="expected a JSON object with a non-empty amendments array", + ) + if len(values) > MAX_DECISION_BATCH: + raise OpenBBQError( + "asr_amendments_too_large", + count=len(values), + max=MAX_DECISION_BATCH, + fix="apply at most 20 contextual corrections at a time", + ) + try: + return [AsrAmendment.model_validate(value) for value in values] + except (ValidationError, ValueError, TypeError) as error: + raise OpenBBQError( + "asr_amendments_invalid", + detail=str(error), + ) from error + + def _text_key(text: str) -> str: return "".join(character for character in text.casefold() if character.isalnum()) @@ -559,7 +623,7 @@ def merge_decisions( current.update( (issue_id, decision) for issue_id, decision in review.decisions.items() - if issue_id in by_id + if issue_id in by_id or issue_id.startswith("m:s") ) current.update(decisions) replacements: dict[str, str] = {} @@ -585,6 +649,105 @@ def merge_decisions( ) +def _manual_amendment_id(amendment: AsrAmendment) -> str: + digest = hashlib.sha256( + f"{amendment.segment_id}|{amendment.find.casefold()}".encode() + ).hexdigest()[:10] + return f"m:s{amendment.segment_id}:{digest}" + + +def merge_amendments( + transcript: Transcript, + review: AsrReview | None, + amendments: list[AsrAmendment], + *, + max_prob: float | None = None, +) -> tuple[AsrReview, list[str]]: + """Merge agent-found corrections that are not tied to detector issue ids.""" + + if not amendments or len(amendments) > MAX_DECISION_BATCH: + raise OpenBBQError( + "asr_amendments_too_large" if amendments else "asr_amendments_invalid", + count=len(amendments), + max=MAX_DECISION_BATCH, + fix="apply from 1 to 20 contextual corrections at a time", + ) + fingerprint = transcript_hash(transcript) + threshold = _validate_max_prob( + max_prob + if max_prob is not None + else review.max_prob + if review is not None + else DEFAULT_MAX_PROB + ) + if review is not None and ( + review.transcript_hash != fingerprint or review.max_prob != threshold + ): + raise OpenBBQError( + "asr_review_stale", + fix="rerun `openbbq asr check` and resolve the current transcript first", + ) + + segments = {segment.id: segment for segment in transcript.segments} + current = dict(review.decisions) if review is not None else {} + applied_ids: list[str] = [] + existing_replacements = { + (decision.find or "").casefold(): decision.replacement or "" + for decision in current.values() + if decision.action == "replace" and decision.find + } + active_text = corrector(review) + for amendment in amendments: + segment = segments.get(amendment.segment_id) + if segment is None: + raise OpenBBQError( + "asr_amendment_unknown_segment", + segment_id=amendment.segment_id, + fix="use a segment_id returned by `openbbq glossary audit`", + ) + active_segment_text = active_text(segment.text) + if ( + amendment.find.casefold() not in segment.text.casefold() + and amendment.find.casefold() not in active_segment_text.casefold() + ): + raise OpenBBQError( + "asr_amendment_find_missing", + segment_id=amendment.segment_id, + find=amendment.find, + fix="copy the exact phrase from `openbbq glossary audit`", + ) + issue_id = _manual_amendment_id(amendment) + previous = existing_replacements.get(amendment.find.casefold()) + if ( + previous is not None + and previous != amendment.replacement + and issue_id not in current + ): + raise OpenBBQError( + "asr_decision_conflict", + find=amendment.find, + replacements=[previous, amendment.replacement], + fix="use one replacement for the same source phrase", + ) + current[issue_id] = AsrDecision( + action="replace", + find=amendment.find, + replacement=amendment.replacement, + reason=amendment.reason, + ) + existing_replacements[amendment.find.casefold()] = amendment.replacement + applied_ids.append(issue_id) + + return ( + AsrReview( + transcript_hash=fingerprint, + max_prob=threshold, + decisions=current, + ), + applied_ids, + ) + + def _phrase_pattern(find: str) -> re.Pattern[str]: return re.compile(rf"(? str: corrected = text @@ -680,3 +845,39 @@ def apply(text: str) -> str: return downstream(corrected) return apply + + +def resolved_transcript( + transcript: Transcript, + review: AsrReview | None, + *, + reference_texts: list[str] | tuple[str, ...] = (), +) -> Transcript: + """Return the current review view without mutating ``transcript.json``. + + Stale sidecars are deliberately ignored. When a phrase replacement changes + a segment, its original word list no longer describes the corrected token + surfaces, so the resolved view drops those words instead of presenting + misleading confidence evidence. + """ + + if review is None or review.transcript_hash != transcript_hash(transcript): + return transcript + reviewed = apply_segment_decisions( + transcript, + review, + reference_texts=reference_texts, + ) + fix = corrector(review) + segments: list[Segment] = [] + for segment in reviewed.segments: + text = fix(segment.text) + segments.append( + segment.model_copy( + update={ + "text": text, + "words": segment.words if text == segment.text else None, + } + ) + ) + return reviewed.model_copy(update={"segments": segments}) diff --git a/src/openbbq/core/export.py b/src/openbbq/core/export.py index 1efd352..119c8ce 100644 --- a/src/openbbq/core/export.py +++ b/src/openbbq/core/export.py @@ -501,6 +501,10 @@ def is_bilingual_ass( targets = _targets(translation) def text_key(text: str) -> str: + # ASS uses a literal ``\\N`` token for a rendered line break. It is + # presentation-only and must not become the letter "n" in the semantic + # comparison below. + text = text.replace(r"\N", "").replace(r"\n", "") return "".join(character.casefold() for character in text if character.isalnum()) for index, cue in enumerate(cues.cues): diff --git a/src/openbbq/core/glossary.py b/src/openbbq/core/glossary.py index fdcc538..469787b 100644 --- a/src/openbbq/core/glossary.py +++ b/src/openbbq/core/glossary.py @@ -1,16 +1,17 @@ -"""Glossary library: a global, named named-entity dictionary per series/topic, +"""Glossary library: a global named-entity dictionary per series/topic, stored at ``$OPENBBQ_HOME/glossaries/.json``. The single place that knows the library layout. It loads/validates into the -``glossary@1`` schema and derives the three deterministic consumables: the ASR -**bias** term list, the ``alias -> source`` **correction** map, and **suggest** -candidates mined from a transcript. Pure domain logic — no cli/output; failures -surface as ``OpenBBQError`` (DESIGN glossary spec §3/§6). Authoring the terms -themselves is semantic and stays with the agent (§1.6). +``glossary@1`` schema and derives deterministic ASR **bias**, ``alias -> source`` +**correction**, candidate **suggestion**, atomic term patching, and correction +effect reports. Pure domain logic — no cli/output; failures surface as +``OpenBBQError``. Authoring the terms themselves is semantic and stays with the +agent. """ from __future__ import annotations +import json import os import re from collections.abc import Callable, Iterator @@ -21,7 +22,9 @@ from openbbq.core.workspace import write_text_atomic from openbbq.errors import OpenBBQError -from openbbq.schemas import Glossary, Transcript +from openbbq.schemas import Glossary, Term, Transcript + +MAX_GLOSSARY_PATCH = 20 # --- library layout ----------------------------------------------------------- @@ -87,6 +90,152 @@ def scaffold(name: str, context: str | None = None) -> Path: return path +def save(glossary: Glossary) -> Path: + """Validate and atomically persist a glossary in its canonical location.""" + + path = glossary_path(glossary.name) + path.parent.mkdir(parents=True, exist_ok=True) + validated = Glossary.model_validate(glossary.model_dump(by_alias=True)) + write_text_atomic( + path, + validated.model_dump_json(indent=2, exclude_none=True) + "\n", + ) + return path + + +@dataclass(frozen=True) +class PatchReport: + added: tuple[str, ...] + updated: tuple[str, ...] + unchanged: tuple[str, ...] + aliases_added: int + + +def parse_term_patch(text: str) -> list[Term]: + """Parse ``{"terms": [...]}`` for one bounded, atomic glossary update.""" + + try: + raw = json.loads(text) + except json.JSONDecodeError as error: + raise OpenBBQError( + "glossary_patch_invalid", + detail="expected a JSON object with a non-empty terms array", + ) from error + values = raw.get("terms") if isinstance(raw, dict) else None + if not isinstance(values, list) or not values: + raise OpenBBQError( + "glossary_patch_invalid", + detail="expected a JSON object with a non-empty terms array", + ) + if len(values) > MAX_GLOSSARY_PATCH: + raise OpenBBQError( + "glossary_patch_too_large", + count=len(values), + max=MAX_GLOSSARY_PATCH, + fix="apply at most 20 glossary terms at a time", + ) + try: + terms = [Term.model_validate(value) for value in values] + except (ValidationError, ValueError, TypeError) as error: + raise OpenBBQError("glossary_patch_invalid", detail=str(error)) from error + for term in terms: + provided = set(term.model_fields_set) + term.source = term.source.strip() + if not term.source: + raise OpenBBQError( + "glossary_patch_invalid", + detail="term source must not be blank", + ) + term.aliases = [alias.strip() for alias in term.aliases if alias.strip()] + if "target" in provided: + term.target = term.target.strip() if term.target is not None else None + if "note" in provided: + term.note = term.note.strip() if term.note is not None else None + term.model_fields_set.intersection_update(provided) + return terms + + +def _validate_form_ownership(terms: list[Term]) -> None: + owners: dict[str, str] = {} + for term in terms: + owner = term.source.casefold() + for form in (term.source, *term.aliases): + key = form.casefold() + previous = owners.get(key) + if previous is not None and previous != owner: + raise OpenBBQError( + "glossary_form_conflict", + form=form, + sources=sorted({previous, owner}), + fix="keep each canonical spelling or alias owned by one term", + ) + owners[key] = owner + + +def upsert_terms( + glossary: Glossary, patches: list[Term] +) -> tuple[Glossary, PatchReport]: + """Merge validated terms in memory; callers save only after the whole batch passes.""" + + terms = [term.model_copy(deep=True) for term in glossary.terms] + by_source = {term.source.casefold(): index for index, term in enumerate(terms)} + added: list[str] = [] + updated: list[str] = [] + unchanged: list[str] = [] + aliases_added = 0 + + for patch in patches: + key = patch.source.casefold() + index = by_source.get(key) + if index is None: + aliases = list( + dict.fromkeys( + alias for alias in patch.aliases if alias.casefold() != key + ) + ) + term = patch.model_copy(update={"aliases": aliases}) + terms.append(term) + by_source[key] = len(terms) - 1 + added.append(term.source) + aliases_added += len(aliases) + continue + + current = terms[index] + before = current.model_dump() + aliases = list(current.aliases) + known_aliases = {alias.casefold() for alias in aliases} + for alias in patch.aliases: + alias_key = alias.casefold() + if alias_key == key or alias_key in known_aliases: + continue + aliases.append(alias) + known_aliases.add(alias_key) + aliases_added += 1 + changes: dict[str, object] = {"aliases": aliases} + if "target" in patch.model_fields_set: + changes["target"] = patch.target + if "note" in patch.model_fields_set: + changes["note"] = patch.note + if "keep" in patch.model_fields_set: + changes["keep"] = patch.keep + merged = current.model_copy(update=changes) + terms[index] = merged + if merged.model_dump() == before: + unchanged.append(current.source) + else: + updated.append(current.source) + + _validate_form_ownership(terms) + result = glossary.model_copy(update={"terms": terms}) + Glossary.model_validate(result.model_dump(by_alias=True)) + return result, PatchReport( + added=tuple(added), + updated=tuple(updated), + unchanged=tuple(unchanged), + aliases_added=aliases_added, + ) + + # --- touchpoint 1: ASR biasing ------------------------------------------------ @@ -153,6 +302,74 @@ def fix(text: str) -> str: return fix +@dataclass(frozen=True) +class AliasApplication: + source: str + alias: str + count: int + + +class CorrectionTracker: + """Callable glossary corrector that also records whether the binding mattered.""" + + def __init__(self, glossary: Glossary | None): + self.glossary = glossary + self.matched_terms: set[str] = set() + self._applications: dict[tuple[str, str], int] = {} + self._forms: list[tuple[str, str]] = [] + self._aliases: list[tuple[re.Pattern[str], str, str]] = [] + if glossary is None: + return + for term in glossary.terms: + for form in (term.source, *term.aliases): + clean = form.strip() + if clean: + self._forms.append((clean, term.source)) + for alias in term.aliases: + clean = alias.strip() + if clean and clean.casefold() != term.source.casefold(): + self._aliases.append( + ( + re.compile(_boundaried(clean), re.IGNORECASE), + term.source, + clean, + ) + ) + self._forms.sort(key=lambda pair: len(pair[0]), reverse=True) + self._aliases.sort(key=lambda item: len(item[2]), reverse=True) + + def __call__(self, text: str) -> str: + for form, source in self._forms: + if contains_term(text, form): + self.matched_terms.add(source) + for pattern, source, alias in self._aliases: + text, count = pattern.subn(source, text) + if count: + key = (source, alias) + self._applications[key] = self._applications.get(key, 0) + count + self.matched_terms.add(source) + return text + + @property + def alias_applications(self) -> list[AliasApplication]: + return [ + AliasApplication(source=source, alias=alias, count=count) + for (source, alias), count in sorted(self._applications.items()) + ] + + +def matched_terms(glossary: Glossary | None, text: str) -> list[str]: + """Canonical terms whose source or aliases occur in one text span.""" + + if glossary is None: + return [] + return [ + term.source + for term in glossary.terms + if any(contains_term(text, form) for form in (term.source, *term.aliases)) + ] + + # --- touchpoint 3 helper + suggest: known forms / candidate mining ------------ @@ -242,5 +459,7 @@ def suggest_candidates( candidates.append(Candidate(surface, count, avg, example[surface])) # Most suspicious first: lowest confidence, then most frequent. - candidates.sort(key=lambda c: (c.avg_prob if c.avg_prob is not None else 1.0, -c.count)) + candidates.sort( + key=lambda c: (c.avg_prob if c.avg_prob is not None else 1.0, -c.count) + ) return candidates[:limit] diff --git a/src/openbbq/schemas/__init__.py b/src/openbbq/schemas/__init__.py index b4ac75c..9c6f486 100644 --- a/src/openbbq/schemas/__init__.py +++ b/src/openbbq/schemas/__init__.py @@ -1,5 +1,5 @@ from .common import OpenBBQModel, Seconds, Stage, StageStatus -from .asr_review import AsrDecision, AsrReview +from .asr_review import AsrAmendment, AsrDecision, AsrReview from .cues import Budget, Cue, Cues, SegmentParams from .glossary import Glossary, Term from .manifest import Manifest, Progress, Source, SourceType, StageState @@ -22,6 +22,7 @@ "Stage", "StageStatus", # asr-review@1 + "AsrAmendment", "AsrDecision", "AsrReview", # transcript@1 diff --git a/src/openbbq/schemas/asr_review.py b/src/openbbq/schemas/asr_review.py index 16be926..aeddc03 100644 --- a/src/openbbq/schemas/asr_review.py +++ b/src/openbbq/schemas/asr_review.py @@ -33,13 +33,40 @@ def validate_action_fields(self) -> AsrDecision: return self +class AsrAmendment(OpenBBQModel): + """Agent-authored correction discovered during contextual transcript audit. + + Unlike detector decisions, an amendment does not need a pre-existing issue + id. The segment id anchors validation and the exact phrase remains the + deterministic replacement key consumed by segmentation. + """ + + segment_id: int + find: str + replacement: str + reason: str + + @model_validator(mode="after") + def validate_fields(self) -> AsrAmendment: + self.find = self.find.strip() + self.replacement = self.replacement.strip() + self.reason = self.reason.strip() + if not self.find: + raise ValueError("find must not be blank") + if not self.replacement: + raise ValueError("replacement must not be blank") + if self.find.casefold() == self.replacement.casefold(): + raise ValueError("replacement must differ from find") + if not self.reason: + raise ValueError("reason must not be blank") + return self + + class AsrReview(OpenBBQModel): schema_: Annotated[ Literal["openbbq/asr-review@1", "openbbq/asr-review@2"], Field(alias="schema"), - ] = ( - "openbbq/asr-review@2" - ) + ] = "openbbq/asr-review@2" transcript_hash: str max_prob: float decisions: dict[str, AsrDecision] = Field(default_factory=dict) diff --git a/src/openbbq/skills/openbbq-subtitles/SKILL.md b/src/openbbq/skills/openbbq-subtitles/SKILL.md index 8aa93ff..04e7c3a 100644 --- a/src/openbbq/skills/openbbq-subtitles/SKILL.md +++ b/src/openbbq/skills/openbbq-subtitles/SKILL.md @@ -24,7 +24,9 @@ video,” run the full workflow without asking about routine options. Infer the target language from the user's language (a Chinese request defaults to `zh`), produce a hard-subtitled bilingual ASS video, and use `fansub` for landscape. Pause only for genuinely ambiguous language, rights scope, or external -permission. Do not call the task complete until `delivery check` passes. +permission. The default flow does not infer where text may appear in the video +or perform visual-layout rework. Do not call the task complete until +`delivery check` passes. ## Required Rules @@ -51,13 +53,13 @@ permission. Do not call the task complete until `delivery check` passes. outside the sandbox. - For the default bilingual-video workflow, do not burn SRT. Export bilingual ASS and burn ASS. -- Pick ASS presets by target surface: `fansub` for prominent bilingual subtitles, - `fansub-compact` for lower-third conflicts, overlap, or a crowded subtitle - stack, and `mobile` for 9:16 vertical video. -- Final delivery must not skip the ASR uncertainty gate, risk-ranked translation - audit, or structured QA. A rendered file is not a visual observation. Without - actual image input, report `visual_status: not_performed` and never run - `qa attest`. +- Use `fansub` for landscape and `mobile` for 9:16 vertical video. Do not infer + on-screen text positions from sampled frames or automatically switch to + `fansub-compact`; use that preset only when the user explicitly requests it. +- Final delivery must not skip the ASR uncertainty gate, translation audit, + bilingual ASS verification, burn provenance, or non-empty artifact check. + Visual QA is not part of the default delivery gate. Models without image input + should skip it; this is not a quality failure or delivery failure. ## When To Read References @@ -65,7 +67,7 @@ permission. Do not call the task complete until `delivery check` passes. heavy content, or whenever `glossary suggest` returns candidates: read `references/glossary.md`. - For full YouTube/local-file command templates, translation batch format, and - completion QA: read `references/workflows.md`. + completion checks: read `references/workflows.md`. - For conceptual answers or checking an existing workspace state, this file is usually enough. @@ -88,49 +90,59 @@ permission. Do not call the task complete until `delivery check` passes. 5. Transcribe, usually with `openbbq transcribe --workspace --model large-v3-turbo --language --gpu`. If the sandbox cannot use GPU, follow the required rule above. -6. ASR review: run `openbbq --json asr check --workspace `, then read bounded +6. Detector-guided ASR review: run `openbbq --json asr check --workspace `, then read bounded `asr batch --limit 20` pages. Resolve low-confidence words, repeated segments, impossible word rates, and title/author entity conflicts; overlapping YouTube reference captions appear when available. Use accept/replace for words and entities, keep_first/drop for hallucinated repetitions, and whole-segment replace only for a damaged segment. Every decision needs evidence. Continue - only at `ready: true`. -7. Named-entity pass: run - `openbbq glossary suggest --workspace `. Use `references/glossary.md` to - actively audit ASR proper-noun mistakes, spelling variants, and new key terms; - update the glossary before `segment`. If `segment` already ran, rerun - `segment` and `translate init` after updating the glossary. -8. Segment, then initialize translation with `translate init --max-lines + only at `ready: true`. Ready means detector-found issues are resolved; it does + not prove every ASR word is correct. +7. Full contextual source audit: run `glossary suggest`, then page through every + `openbbq --json glossary audit --workspace --limit 20` batch, including + high-confidence words. Judge errors from the full sentence, previous/next + context, topic, names, and optional reference caption. Probability and + reference text are advisory. Use `asr amend` for one-off/context-sensitive + corrections that have no detector issue id. Use bounded `glossary apply` + patches for reusable terms and ASR variants; do not turn ambiguous common + words into global aliases. Continue until audit `remaining` is zero. +8. Glossary verification: update the glossary before `segment`, then inspect + segment's `glossary_matched_terms`, `glossary_aliases_applied`, and + `glossary_no_effect`. Binding alone is not usage. If `segment` already ran, + rerun `segment` and `translate init` after glossary/ASR updates. +9. Segment, then initialize translation with `translate init --max-lines 2`. For bilingual video, use the second target line before deleting meaning. -9. Fill translations: for many cues, first read a bounded batch with +10. Fill translations: for many cues, first read a bounded batch with `openbbq --json translate batch --workspace --from --limit 20 --only-missing`, then write a `{id: target}` batch JSON and merge it with `translate apply`. Do not load the entire worksheet into context. -10. Mechanical check: run `openbbq translate check --workspace ` and +11. Mechanical check: run `openbbq translate check --workspace ` and clear `missing`, `over_budget`, `zero_budget`, `term_issues`, and `quality_issues`. Continue only at `ready: true`. The command is read-only; formal `export` completes the translation stage. -11. Full-coverage semantic audit: page through `translate audit +12. Full-coverage semantic audit: page through `translate audit --coverage all --limit 20`. Risky cues come first, but every translated cue must be accepted or revised against its previous and next context. Never bulk-accept because deterministic checks passed. Editing a cue invalidates its own and adjacent context reviews; rerun check/audit until ready. -12. Human visual review: when the user asks for final manual review, cue timing +13. Human visual review: when the user asks for final manual review, cue timing changes, or sentence-boundary fixes, run `openbbq review --workspace --to `. The review service safely synchronizes cues and every worksheet; do not edit those files concurrently from another Agent. -13. Export and burn: default to bilingual ASS, then burn. When a review file +14. Export and burn: default to bilingual ASS, then burn. When a review file exists, incomplete review blocks export; use `--allow-unreviewed` only for an intentional draft. Pick `--ass-preset` by target surface. `--allow-quality-warnings` and `burn --allow-stale` are only for a user-requested draft or intentional external/manual artifact, never a final delivery. -14. Completion QA: `qa render` defaults to up to seven risk frames covering - boundaries, midpoint, long lines, high CPS, and short cues. Inspect every - image before attesting. A failed attestation must include structured - `--issue` values; use `fansub-compact` for layout conflicts, or return to ASR - and semantic audit for content errors. Without image input, never attest. -15. Hard delivery gate: run `openbbq --json delivery check --workspace --to +15. Completion check: after burn, run `delivery check` directly. It verifies ASR, + segmentation, translation, full semantic audit, bilingual ASS, export/burn + freshness, burn provenance, and a non-empty MP4. The default flow does not + require `qa render`, risk-frame inspection, or `qa attest`. Use those commands + only as optional diagnostics when the user explicitly requests visual review + and the current model can inspect images; their result must not trigger an + automatic preset switch or reburn. +16. Hard delivery gate: run `openbbq --json delivery check --workspace --to `. Deliver only when it exits 0 with `ready: true`; follow its returned fix instead of explaining away a failed gate. @@ -148,8 +160,10 @@ Core decisions: bilingual hard subtitles where the English source text is rendered. - Confirmed new key terms: add them if the translation is known; otherwise ask the user or mark them as pending in `note`. -- One-off common words / low-confidence candidates: do not add them to the - glossary and do not block the workflow. +- One-off/context-sensitive mistakes: use `asr amend`; do not create an unsafe + global alias for a common word that may be correct elsewhere. +- Correct one-off words or irrelevant candidates: do not add them to the + glossary. Confidence is only an ordering clue, never the semantic verdict. See `references/glossary.md` for the full schema, examples, and active audit workflow. diff --git a/src/openbbq/skills/openbbq-subtitles/SKILL.zh-CN.md b/src/openbbq/skills/openbbq-subtitles/SKILL.zh-CN.md index 9df8283..ae1d042 100644 --- a/src/openbbq/skills/openbbq-subtitles/SKILL.zh-CN.md +++ b/src/openbbq/skills/openbbq-subtitles/SKILL.zh-CN.md @@ -14,7 +14,8 @@ 用户只给一个视频并说“帮我把这个视频制作成双语字幕的视频”时,直接执行完整工作流, 不要追问常规选项:目标语默认取用户当前使用的语言(中文请求默认 `zh`),输出默认双语 ASS 硬字幕视频,横屏默认 `fansub`。只有版权范围、目标语言确实不明或需要外部权限时才停下来 -询问。最终只有 `delivery check` 通过才可以说任务完成。 +询问。默认流程不推断视频画面中的文字位置,也不做视觉排版返工。最终只有 +`delivery check` 通过才可以说任务完成。 ## 必读规则 @@ -34,17 +35,17 @@ ASS 硬字幕视频,横屏默认 `fansub`。只有版权范围、目标语言 - 沙箱环境通常不能用本机 GPU 做 ASR;需要 GPU 时询问用户是否允许在沙箱外运行 `transcribe`。 - 默认双语视频不要烧录 SRT;导出双语 ASS,再烧录 ASS。 -- ASS preset 按目标画面选:`fansub` 更醒目,`fansub-compact` 用于下三分之一冲突、 - 遮挡或字幕堆叠,`mobile` 适合 9:16 竖屏。 -- 最终交付不得跳过 ASR 不确定词门禁、翻译风险审计或结构化 QA。不得把“成功截帧” - 说成“已看过画面”;没有实际图像输入能力时,必须如实报告 `visual_status: - not_performed`,不能运行 `qa attest`。 +- 横屏默认 `fansub`,9:16 竖屏用 `mobile`。不要根据抽帧猜测视频文字位置,也不要 + 自动切换到 `fansub-compact`;该预设只在用户明确指定时使用。 +- 最终交付不得跳过 ASR 不确定词门禁、翻译风险审计、双语 ASS 校验、烧录 provenance + 和非空文件检查。视觉 QA 不属于默认交付门禁;没有图像输入能力的模型应直接跳过, + 不构成质量扣分或交付失败。 ## 何时读取 reference - 处理系列、动漫、游戏、品牌、课程、访谈等专名密集内容,或 `glossary suggest` 出现候选:读取 `references/glossary.zh-CN.md`。 -- 需要完整 YouTube/本地文件命令模板、翻译批次格式、完成 QA:读取 +- 需要完整 YouTube/本地文件命令模板、翻译批次格式、完成交付检查:读取 `references/workflows.zh-CN.md`。 - 如果只是回答概念性问题或检查已有 workspace 状态,先用本文件即可。 @@ -61,39 +62,44 @@ ASS 硬字幕视频,横屏默认 `fansub`。只有版权范围、目标语言 4. 本地文件跳过 fetch;YouTube fetch 后继续 `extract-audio`。 5. 转写:通常用 `openbbq transcribe --workspace --model large-v3-turbo --language --gpu`。沙箱无法用 GPU 时,按必读规则请求授权或改 CPU。 -6. ASR 审核:先跑 `openbbq --json asr check --workspace `;有未决项时,用 +6. 检测器引导的 ASR 审核:先跑 `openbbq --json asr check --workspace `;有未决项时,用 `asr batch --limit 20` 分批读取。除低置信词外,还要处理重复段、异常词速和标题/作者 实体冲突;YouTube 参考字幕可用时 batch 会附带同时间文字。词和实体使用 accept/replace,幻觉重复段使用 keep_first/drop,整段损坏才用 replace。每个决定必须有 - 具体证据,不能批量盲目接受。直到 `ready: true` 才能继续。 -7. 专名处理:跑 `openbbq glossary suggest --workspace `。按 - `references/glossary.zh-CN.md` 主动审计 ASR 专名错误、拼写变体和新关键术语; - 更新 glossary 后再 `segment`。如果 `segment` 已跑过,更新 glossary 后重跑 - `segment` 和 `translate init`。 -8. 分段并初始化翻译:`segment` 后跑 `translate init --max-lines 2`。默认 + 具体证据,不能批量盲目接受。直到 `ready: true` 才能继续;ready 只表示检测器发现的 + 问题已解决,不代表所有 ASR 原文都正确。 +7. 全量上下文 source 审计:先跑 `glossary suggest`,再翻完所有 + `openbbq --json glossary audit --workspace --limit 20` 批次,高置信词也不能跳过。 + 根据完整句子、前后段、主题、专名和可选参考字幕判断,概率与参考文字只作证据。 + 没有 issue id 的一次性/依赖上下文错误用 `asr amend`;可复用术语和 ASR 变体用有界的 + `glossary apply` patch。不要把有歧义的普通词做成全局 alias。直到 `remaining: 0`。 +8. Glossary 生效检查:更新后再跑 `segment`,检查 `glossary_matched_terms`、 + `glossary_aliases_applied` 和 `glossary_no_effect`;已绑定不等于实际使用。如果之前已跑 + segment,ASR/glossary 更新后重跑 `segment` 和 `translate init`。 +9. 分段并初始化翻译:`segment` 后跑 `translate init --max-lines 2`。默认 双语视频先给目标语两行空间,不要为了单行限制删掉原意。 -9. 填译文:大量 cue 先用 `openbbq --json translate batch --workspace +10. 填译文:大量 cue 先用 `openbbq --json translate batch --workspace --from --limit 20 --only-missing` 读取有界批次,再写 `{id: target}` 批次 JSON,并用 `translate apply` 合并。不要把完整 worksheet 塞进上下文。 -10. 机械检查:跑 `openbbq translate check --workspace `,清掉 `missing`、 +11. 机械检查:跑 `openbbq translate check --workspace `,清掉 `missing`、 `over_budget`、`zero_budget`、`term_issues` 和 `quality_issues`;只有输出 `ready: true` 才能进入审计。此命令只读;翻译 stage 由正式 `export` 完成。 -11. 全覆盖语义审计:用 `translate audit --coverage all --limit 20` 分批读取; +12. 全覆盖语义审计:用 `translate audit --coverage all --limit 20` 分批读取; 高风险 cue 优先,但每个已翻译 cue 都必须结合前后各一条上下文逐条 accept 或 revise, 并用 `translate audit-apply` 写入带理由的决策。不能因为机械检查通过就批量接受。 修改任一译文会使本条及相邻上下文审校失效,必须重新 check/audit,直到 ready。 -12. 人工可视化审核:用户要求最终人工校对、调整 cue 时间或修复断句时,使用 +13. 人工可视化审核:用户要求最终人工校对、调整 cue 时间或修复断句时,使用 `openbbq review --workspace --to `。审核页会受控同步 cues 与所有 worksheet;不要同时让其他 Agent 直接编辑这些文件。 -13. 导出和烧录:默认导出双语 ASS,再 burn。存在 review 文件时,未完成审核会 +14. 导出和烧录:默认导出双语 ASS,再 burn。存在 review 文件时,未完成审核会 阻止导出;只有明确需要草稿时才用 `--allow-unreviewed`。导出时可按场景选择 `--ass-preset`。`--allow-quality-warnings` 和 `burn --allow-stale` 只用于用户 明确要求的草稿或手工外部产物,不能用于最终交付。 -14. 完成 QA:运行 `qa render`(默认最多 7 张首尾/中段/长句/高 CPS/短时长风险帧) - 和只读 `qa check`。有图像输入能力时读取每张 frame;失败 attestation 必须用 - `--issue` 记录结构化问题。版式冲突优先改用 `fansub-compact` 后重新 export、burn、 - render;内容错误回到 ASR/翻译审校。没有图像输入能力时不得 attest。 -15. 硬交付门禁:最后运行 `openbbq --json delivery check --workspace --to `。 +15. 完成交付检查:burn 后直接运行 `delivery check`。它检查 ASR、分段、翻译、全量 + 语义审计、双语 ASS、export/burn freshness、烧录 provenance 和非空 MP4;默认不需要 + `qa render`、查看风险帧或 `qa attest`。只有用户明确要求视觉检查且当前模型有图像 + 能力时,才把 `qa render/check/attest` 作为可选诊断;结果不触发自动预设切换或重烧录。 +16. 硬交付门禁:最后运行 `openbbq --json delivery check --workspace --to `。 只有退出码 0 且 `ready: true` 才能交付;否则严格执行返回的 fix,不能用文字解释绕过。 ## Glossary 原则 @@ -108,7 +114,9 @@ Glossary 是活文档,用于 ASR 偏置、segment 纠错和 `translate check` 会把英文源文烧进成片。 - 确定的新关键术语:译名确定则加入 glossary;不确定先问用户或在 `note` 标记 待确认。 -- 一次性普通词/低置信候选:不加入 glossary,不阻塞流程。 +- 一次性或依赖上下文的错误:使用 `asr amend`;不要把在其他上下文可能正确的普通词 + 做成危险的全局 alias。 +- 正确的一次性普通词/无关候选:不加入 glossary。置信度只用于排序,不能替代语义判断。 详细格式、例子和主动审计流程见 `references/glossary.zh-CN.md`。 diff --git a/src/openbbq/skills/openbbq-subtitles/references/glossary.md b/src/openbbq/skills/openbbq-subtitles/references/glossary.md index acaf27e..6cf11ed 100644 --- a/src/openbbq/skills/openbbq-subtitles/references/glossary.md +++ b/src/openbbq/skills/openbbq-subtitles/references/glossary.md @@ -45,9 +45,35 @@ openbbq glossary new frieren --context "Frieren: Beyond Journey's End, fantasy a Do not invent official translations. Ask the user for uncertain translations, or mark them as pending in `note`. -## File Format +## Safe Maintenance -Maintain `~/.openbbq/glossaries/.json`: +Do not edit `~/.openbbq/glossaries/.json` directly. Write a bounded patch +and apply it atomically: + +```json +{ + "terms": [ + { + "source": "Andy Matuschak", + "target": "安迪·马图沙克", + "aliases": ["Annie Matushak"], + "note": "researcher; ASR variant confirmed from context" + } + ] +} +``` + +```bash +openbbq glossary apply --workspace workspaces/demo glossary-terms.json +``` + +The command adds new canonical terms, merges new aliases into existing terms, +and preserves omitted target/note/keep values. Conflicting ownership of the same +source or alias rejects the whole patch. A successful update invalidates +`segment` and later artifacts in the workspace; an idempotent no-op patch does +not invalidate anything. + +The stored fields are: - `source`: canonical source text used by ASR biasing, correction, and term checks. @@ -85,16 +111,25 @@ After `transcribe`, always run: ```bash openbbq glossary suggest --workspace workspaces/demo +openbbq --json glossary audit --workspace workspaces/demo --offset 0 --limit 20 ``` -Then actively audit both candidates and transcript text. Do not treat -suggestions as optional notes. For bilingual ASS / hard-subtitled videos, audit -source lines because the English source text is rendered in the final video; a -correct translated line is not enough. +`suggest` is a prioritization hint, not a completeness check. Page through every +`audit` batch, including segments whose words have high probabilities. Each item +includes the resolved source, raw source when it changed, previous/next segment, +word probabilities, overlapping reference caption when available, and current +glossary matches. Judge likely errors from meaning, grammar, topic, names, and +surrounding discourse. Probabilities and reference captions are evidence, not +ground truth. + +For bilingual ASS / hard-subtitled videos, this full source audit is required +because the source text is rendered in the final video; a correct translated +line is not enough. Continue with the returned `next_offset` until `remaining` +is zero. -Classify candidates into three groups: +Classify findings into four groups: -1. **ASR mistakes or spelling variants** +1. **Reusable ASR mistakes or spelling variants** Known proper nouns misheard by ASR, misspelled character names, joined/split work titles. Add the incorrect form to an existing term's `aliases`, or add a new canonical `source` term and put the incorrect form in `aliases`. These @@ -103,9 +138,30 @@ Classify candidates into three groups: Recurring names, places, organizations, abilities, work titles, brands, and other important series terminology. If the translation is known, add it to the glossary; if not, ask the user or mark it as pending in `note`. -3. **One-off common words / low-confidence candidates** - Do not add them to the glossary. Mention them in a reply or work note if - useful, but do not block the workflow on them. +3. **One-off or context-sensitive ASR mistakes** + Do not create a dangerous global alias such as a common word that may be + correct in another context. Record the exact current correction instead: + + ```json + { + "amendments": [ + { + "segment_id": 12, + "find": "hot tick", + "replacement": "hot take", + "reason": "The surrounding sentence uses the idiom hot take." + } + ] + } + ``` + + ```bash + openbbq asr amend --workspace workspaces/demo asr-amendments.json + ``` + +4. **Correct one-off words / irrelevant candidates** + Do not add them to the glossary. Probability alone is never a reason to + accept or replace a word. Update the glossary before `segment`. If `segment` already ran, rerun: @@ -116,6 +172,10 @@ openbbq translate init zh --workspace workspaces/demo --max-lines 2 ## Checks +- Inspect `segment --json`: `glossary_matched_terms` shows canonical terms that + actually appeared, `glossary_aliases_applied` proves known ASR variants were + corrected, and `glossary_no_effect: true` means a binding existed but changed + or matched nothing. Binding a glossary is not proof it was useful. - Fix all `term_issues` from `translate check`. - Passing `translate check` does not prove source cues are free of ASR proper-noun errors. diff --git a/src/openbbq/skills/openbbq-subtitles/references/glossary.zh-CN.md b/src/openbbq/skills/openbbq-subtitles/references/glossary.zh-CN.md index 4d22f1b..be3a29e 100644 --- a/src/openbbq/skills/openbbq-subtitles/references/glossary.zh-CN.md +++ b/src/openbbq/skills/openbbq-subtitles/references/glossary.zh-CN.md @@ -39,9 +39,33 @@ openbbq glossary new frieren --context "葬送的芙莉莲,奇幻动画" 不要擅自发明官方译名;不确定的译名先问用户,或在 `note` 里标记待确认。 -## 文件格式 +## 安全维护 -维护 `~/.openbbq/glossaries/.json`: +不要直接编辑 `~/.openbbq/glossaries/.json`。先写一个有界 patch,再原子应用: + +```json +{ + "terms": [ + { + "source": "Andy Matuschak", + "target": "安迪·马图沙克", + "aliases": ["Annie Matushak"], + "note": "研究者;根据上下文确认的 ASR 变体" + } + ] +} +``` + +```bash +openbbq glossary apply --workspace workspaces/demo glossary-terms.json +``` + +该命令新增 canonical term、合并既有 term 的新 alias,并保留 patch 中未提供的 +target/note/keep。若同一 source 或 alias 被不同 term 占用,整批拒绝,不会留下半写状态。 +成功更新会让当前 workspace 的 segment 及后续产物失效。 +重复应用内容完全相同的 no-op patch 不会让产物失效。 + +存储字段如下: - `source`:标准原文,供 ASR bias、纠错和译名检查使用。 - `target`:既定译名。 @@ -77,22 +101,48 @@ openbbq glossary new frieren --context "葬送的芙莉莲,奇幻动画" ```bash openbbq glossary suggest --workspace workspaces/demo +openbbq --json glossary audit --workspace workspaces/demo --offset 0 --limit 20 ``` -然后主动审计候选和转写文本,不要只把候选当备注。双语 ASS/硬字幕尤其要审计 -source 行,因为英文源文也会出现在成片里;中文译文正确不代表成片正确。 +`suggest` 只负责提示优先级,不是完整性检查。必须翻完所有 `audit` 批次,高置信词所在 +段也不能跳过。每项会给出已应用 ASR 决策的 source、变化前 raw source、前后段、词级 +概率、同时间参考字幕(如有)和当前 glossary 命中。Agent 应根据语义、语法、主题、 +专名和上下文自行判断;概率与参考字幕都只是证据,不是真值。 + +双语 ASS/硬字幕必须做这次全量 source 审计,因为原文会直接烧进成片;中文译文正确不 +代表成片正确。沿返回的 `next_offset` 继续,直到 `remaining` 为 0。 -把候选分三类: +把审计发现分四类: -1. **ASR 错误或拼写变体** +1. **可复用的 ASR 错误或拼写变体** 已知专名被听错、角色名拼错、作品名粘连/拆错。把错误形式加入既有 term 的 `aliases`,或新增 canonical `source` term 并把错误形式放入 `aliases`。 这类错误不应继续流到 `segment`。 2. **确定的新关键术语** 同系列会反复出现的人名、地名、组织、技能、作品名、品牌等。译名确定就加入 glossary;不确定就问用户或在 `note` 标记待确认。 -3. **一次性普通词/低置信候选** - 不加入 glossary;可在回复或工作备注中记录,但不要阻塞流程。 +3. **一次性或依赖上下文的 ASR 错误** + 不要把可能在别处成立的普通词做成危险的全局 alias。只记录本次精确修正: + + ```json + { + "amendments": [ + { + "segment_id": 12, + "find": "hot tick", + "replacement": "hot take", + "reason": "结合前后句可知这里使用的是固定表达 hot take。" + } + ] + } + ``` + + ```bash + openbbq asr amend --workspace workspaces/demo asr-amendments.json + ``` + +4. **正确的一次性普通词/无关候选** + 不加入 glossary。不能只凭概率决定 accept 或 replace。 更新 glossary 后必须在 `segment` 前完成。如果 `segment` 已跑过,更新后重跑: @@ -103,6 +153,10 @@ openbbq translate init zh --workspace workspaces/demo --max-lines 2 ## 检查点 +- 检查 `segment --json`:`glossary_matched_terms` 表示实际出现的 canonical term, + `glossary_aliases_applied` 证明已知 ASR 变体确实被纠正; + `glossary_no_effect: true` 表示虽然绑定了 glossary,但没有任何命中或纠错。 + “已绑定”不等于“已使用”。 - `translate check` 的 `term_issues` 必须修完。 - 通过 `translate check` 不代表 source cue 没有 ASR 专名错误。 - 双语输出前抽查 `cues.json` 或 `translation..json` 的 source 行。 diff --git a/src/openbbq/skills/openbbq-subtitles/references/workflows.md b/src/openbbq/skills/openbbq-subtitles/references/workflows.md index 1a19bf5..04dd0d5 100644 --- a/src/openbbq/skills/openbbq-subtitles/references/workflows.md +++ b/src/openbbq/skills/openbbq-subtitles/references/workflows.md @@ -10,6 +10,7 @@ openbbq extract-audio --workspace workspaces/demo openbbq transcribe --workspace workspaces/demo --model large-v3-turbo --language en --gpu openbbq --json asr check --workspace workspaces/demo openbbq glossary suggest --workspace workspaces/demo +openbbq --json glossary audit --workspace workspaces/demo --limit 20 openbbq segment --workspace workspaces/demo openbbq translate init zh --workspace workspaces/demo --max-lines 2 ``` @@ -33,6 +34,7 @@ openbbq extract-audio --workspace workspaces/demo openbbq transcribe --workspace workspaces/demo --model large-v3-turbo --language en --gpu openbbq --json asr check --workspace workspaces/demo openbbq glossary suggest --workspace workspaces/demo +openbbq --json glossary audit --workspace workspaces/demo --limit 20 openbbq segment --workspace workspaces/demo openbbq translate init zh --workspace workspaces/demo --max-lines 2 ``` @@ -70,7 +72,10 @@ openbbq asr apply --workspace workspaces/demo asr-decisions.json ``` Repeat batch/apply until `asr check` is `ready: true`. Never accept blindly to -clear the gate. +clear the gate. Then complete every `glossary audit` page: this second pass is +where the Agent catches semantic ASR errors regardless of confidence. Use +`asr amend` for context-specific errors without an issue id and `glossary apply` +for safe reusable terms/aliases, following `glossary.md`. ## Fill Translations @@ -139,13 +144,11 @@ cue invalidates adjacent context reviews. Minimum requirements: Only export and burn after both mechanical checks and quality self-review pass. -## Completion QA +## Completion Checks ```bash openbbq --json status --workspace workspaces/demo openbbq translate check zh --workspace workspaces/demo -openbbq --json qa render --workspace workspaces/demo -openbbq --json qa check --workspace workspaces/demo openbbq --json delivery check --workspace workspaces/demo --to zh ``` @@ -154,27 +157,18 @@ Confirm: - Relevant manifest stages are complete, with no failed/stale/running state. - `translate check` returns `ready: true`; the final flow did not use `--allow-quality-warnings` or `burn --allow-stale`. -- `qa check` has `mechanical_status: pass`, proving the current MP4 is non-empty - and matches the current source video, ASS, and frame hashes. -- With image input, open every path in `frames`, inspect bilingual content, - wrapping, occlusion, and safe area, then run: - -```bash -openbbq qa attest --workspace workspaces/demo --result pass --reason '' -``` +- `delivery check` returns `ready: true`, proving that the bilingual ASS, source + video, burned MP4, and stage provenance agree and that the MP4 is non-empty. -On failure, record a structured issue. For example, for a lower-third conflict: +Visual QA is not part of the default one-shot flow. Only when the user explicitly +requests it and the current model has image input, optionally run: ```bash -openbbq qa attest --workspace workspaces/demo --result fail \ - --issue lower_third_conflict --reason 'cue 43 overlaps the speaker name card' -openbbq export --workspace workspaces/demo --to zh --mode bilingual --format ass \ - --ass-preset fansub-compact --output out/zh.ass +openbbq --json qa render --workspace workspaces/demo +openbbq --json qa check --workspace workspaces/demo +openbbq qa attest --workspace workspaces/demo --result pass --reason '' ``` -After fixing, rerun burn, QA render, and attestation. Final `delivery check` must -exit 0 with `ready: true`; it combines ASR anomalies, translation checks, -full-context semantic review, artifact freshness, and visual QA. - -- Without image input, never run `qa attest`. Final delivery must explicitly say - `visual_status: not_performed`, not “visual QA passed.” +Visual results are advisory diagnostics only. They do not participate in +`delivery check`, automatically select `fansub-compact`, or trigger a reburn. +Models without image input should simply skip this optional step. diff --git a/src/openbbq/skills/openbbq-subtitles/references/workflows.zh-CN.md b/src/openbbq/skills/openbbq-subtitles/references/workflows.zh-CN.md index 438d85d..788c938 100644 --- a/src/openbbq/skills/openbbq-subtitles/references/workflows.zh-CN.md +++ b/src/openbbq/skills/openbbq-subtitles/references/workflows.zh-CN.md @@ -10,6 +10,7 @@ openbbq extract-audio --workspace workspaces/demo openbbq transcribe --workspace workspaces/demo --model large-v3-turbo --language en --gpu openbbq --json asr check --workspace workspaces/demo openbbq glossary suggest --workspace workspaces/demo +openbbq --json glossary audit --workspace workspaces/demo --limit 20 openbbq segment --workspace workspaces/demo openbbq translate init zh --workspace workspaces/demo --max-lines 2 ``` @@ -32,6 +33,7 @@ openbbq extract-audio --workspace workspaces/demo openbbq transcribe --workspace workspaces/demo --model large-v3-turbo --language en --gpu openbbq --json asr check --workspace workspaces/demo openbbq glossary suggest --workspace workspaces/demo +openbbq --json glossary audit --workspace workspaces/demo --limit 20 openbbq segment --workspace workspaces/demo openbbq translate init zh --workspace workspaces/demo --max-lines 2 ``` @@ -68,7 +70,10 @@ openbbq --json asr batch --workspace workspaces/demo --limit 20 --only-unresolve openbbq asr apply --workspace workspaces/demo asr-decisions.json ``` -重复 batch/apply,直到 `asr check` 返回 `ready: true`。不要为了通过门禁盲目接受。 +重复 batch/apply,直到 `asr check` 返回 `ready: true`。不要为了通过门禁盲目接受。随后 +翻完所有 `glossary audit` 页面;这一步由 Agent 结合语义发现与置信度无关的 ASR 错误。 +没有 issue id 的上下文错误用 `asr amend`,安全且可复用的术语/alias 用 +`glossary apply`,具体规则见 `glossary.zh-CN.md`。 ## 填写译文 @@ -130,13 +135,11 @@ cue 都需要结合前后文审核;修改一条会使相邻上下文审核失 只有通过机械检查和质量自审后,才进入 `export` / `burn`。 -## 完成 QA +## 完成交付检查 ```bash openbbq --json status --workspace workspaces/demo openbbq translate check zh --workspace workspaces/demo -openbbq --json qa render --workspace workspaces/demo -openbbq --json qa check --workspace workspaces/demo openbbq --json delivery check --workspace workspaces/demo --to zh ``` @@ -145,27 +148,16 @@ openbbq --json delivery check --workspace workspaces/demo --to zh - manifest 相关 stage 全部完成,没有 failed/stale/running。 - `translate check` 返回 `ready: true`;最终流程没有使用 `--allow-quality-warnings` 或 `burn --allow-stale`。 -- `qa check` 的 `mechanical_status` 是 `pass`,证明当前 MP4 非空且与当前源视频、 - ASS 和截帧 hash 一致。 -- 有图像输入能力时,实际打开 `frames` 里的每张图片,检查双语内容、`\N` - 换行、遮挡和安全区,再运行: - -```bash -openbbq qa attest --workspace workspaces/demo --result pass --reason '<实际观察>' -``` +- `delivery check` 返回 `ready: true`,证明双语 ASS、源视频、烧录 MP4 和各阶段 + provenance 一致,并且 MP4 非空。 -如果失败,必须记录结构化问题。例如下三分之一冲突: +视觉 QA 不属于默认 one-shot 流程。只有用户明确要求且当前模型有图像能力时,才可选运行: ```bash -openbbq qa attest --workspace workspaces/demo --result fail \ - --issue lower_third_conflict --reason 'cue 43 与视频人物名牌重叠' -openbbq export --workspace workspaces/demo --to zh --mode bilingual --format ass \ - --ass-preset fansub-compact --output out/zh.ass +openbbq --json qa render --workspace workspaces/demo +openbbq --json qa check --workspace workspaces/demo +openbbq qa attest --workspace workspaces/demo --result pass --reason '<实际观察>' ``` -修复后重新 burn、qa render 和 attest。最终 `delivery check` 必须退出码 0 且 -`ready: true`;它会同时检查 ASR 异常、翻译机械门禁、全量上下文语义审校、产物 freshness -和视觉 QA。 - -- 没有图像输入能力时不得运行 `qa attest`;最终说明必须写明 - `visual_status: not_performed`,不能声称“视觉检查通过”。 +视觉结果仅用于用户要求的人工诊断,不参与 `delivery check`,也不自动切换 +`fansub-compact` 或触发重新烧录。没有图像输入能力时直接跳过即可。 diff --git a/tests/test_asr_review.py b/tests/test_asr_review.py index 2c62b65..55e4d1a 100644 --- a/tests/test_asr_review.py +++ b/tests/test_asr_review.py @@ -11,6 +11,7 @@ import typer from openbbq.cli.commands.asr import apply as apply_cmd +from openbbq.cli.commands.asr import amend as amend_cmd from openbbq.cli.commands.asr import batch as batch_cmd from openbbq.cli.commands.asr import check as check_cmd from openbbq.cli.commands.segment import segment as segment_cmd @@ -57,9 +58,7 @@ def _segment( cursor = float(segment_id) timed: list[Word] = [] for word, probability in words: - timed.append( - Word(word=word, start=cursor, end=cursor + 0.25, prob=probability) - ) + timed.append(Word(word=word, start=cursor, end=cursor + 0.25, prob=probability)) cursor += 0.25 return Segment( id=segment_id, @@ -128,7 +127,13 @@ def test_extracts_every_low_probability_occurrence_with_stable_context() -> None _segment( 7, "Thank you to Sean Hongxiu.", - [("Thank", 0.99), ("you", 0.99), ("to", 0.99), ("Sean", 0.38), ("Hongxiu.", 0.86)], + [ + ("Thank", 0.99), + ("you", 0.99), + ("to", 0.99), + ("Sean", 0.38), + ("Hongxiu.", 0.86), + ], ) ) @@ -148,9 +153,7 @@ def test_extracts_every_low_probability_occurrence_with_stable_context() -> None def test_transcript_without_word_probabilities_has_no_gate() -> None: - transcript = _transcript( - Segment(id=0, start=0, end=1, text="hello", words=None) - ) + transcript = _transcript(Segment(id=0, start=0, end=1, text="hello", words=None)) report = asr_review.check(transcript, None) @@ -205,7 +208,9 @@ def test_keep_first_removes_only_duplicate_segments_from_a_repeat_run() -> None: ) transcript = _transcript( *[ - _timed_segment(index, float(index * 2), float(index * 2 + 2), repeated, repeated_words) + _timed_segment( + index, float(index * 2), float(index * 2 + 2), repeated, repeated_words + ) for index in range(1, 5) ], following, @@ -246,7 +251,17 @@ def test_metadata_entity_conflict_catches_high_confidence_name_misspelling() -> 10, 15, "Today I am talking with Jeffrey Litt about software.", - ["Today", "I", "am", "talking", "with", "Jeffrey", "Litt", "about", "software"], + [ + "Today", + "I", + "am", + "talking", + "with", + "Jeffrey", + "Litt", + "about", + "software", + ], ) ) @@ -269,13 +284,21 @@ def test_metadata_entity_replacement_corrects_text_without_collapsing_segment() 10, 15, "Today I am talking with Jeffrey Litt about software.", - ["Today", "I", "am", "talking", "with", "Jeffrey", "Litt", "about", "software"], + [ + "Today", + "I", + "am", + "talking", + "with", + "Jeffrey", + "Litt", + "about", + "software", + ], ) ) references = ["A conversation with Geoffrey Litt"] - issue = asr_review.extract_anomalies( - transcript, reference_texts=references - )[0] + issue = asr_review.extract_anomalies(transcript, reference_texts=references)[0] review = asr_review.merge_decisions( transcript, None, @@ -320,6 +343,23 @@ def test_reference_caption_parser_returns_only_overlapping_caption_text() -> Non ) +def test_reference_caption_evidence_unescapes_and_compacts_rolling_text() -> None: + captions = asr_review.parse_reference_captions( + """WEBVTT + +00:00:01.000 --> 00:00:03.000 +>> Thanks for coming to + +00:00:02.000 --> 00:00:05.000 +>> Thanks for coming to the design engineering track at AI. +""" + ) + + assert asr_review.reference_caption_text(captions, start=2.0, end=3.0) == ( + ">> Thanks for coming to the design engineering track at AI." + ) + + def test_accept_decision_resolves_current_issue() -> None: transcript = _transcript(_segment(0, "Heva!", [("Heva!", 0.36)])) review = asr_review.merge_decisions( @@ -345,7 +385,13 @@ def test_replace_decision_corrects_full_phrase_boundary_safely() -> None: _segment( 206, "Thank you to Sean Hongxiu.", - [("Thank", 0.99), ("you", 0.99), ("to", 0.99), ("Sean", 0.38), ("Hongxiu.", 0.86)], + [ + ("Thank", 0.99), + ("you", 0.99), + ("to", 0.99), + ("Sean", 0.38), + ("Hongxiu.", 0.86), + ], ) ) review = asr_review.merge_decisions( @@ -367,9 +413,123 @@ def test_replace_decision_corrects_full_phrase_boundary_safely() -> None: assert fix("Sean HongxiuExtra") == "Sean HongxiuExtra" +def test_contextual_amendment_corrects_high_confidence_error_without_issue_id() -> None: + transcript = _transcript( + _segment( + 12, + "That is my hot tick about agents.", + [ + ("That", 0.99), + ("is", 0.99), + ("my", 0.99), + ("hot", 0.98), + ("tick", 0.97), + ("about", 0.99), + ("agents.", 0.99), + ], + ) + ) + assert asr_review.check(transcript, None).ready is True + + parsed = asr_review.parse_amendments( + json.dumps( + { + "amendments": [ + { + "segment_id": 12, + "find": "hot tick", + "replacement": "hot take", + "reason": "The surrounding sentence uses the idiom hot take.", + } + ] + } + ) + ) + review, ids = asr_review.merge_amendments(transcript, None, parsed) + + assert ids[0].startswith("m:s12:") + assert asr_review.check(transcript, review).ready is True + assert asr_review.corrector(review)(transcript.segments[0].text) == ( + "That is my hot take about agents." + ) + assert asr_review.resolved_transcript(transcript, review).segments[0].text == ( + "That is my hot take about agents." + ) + + +def test_contextual_amendment_requires_exact_phrase_in_declared_segment() -> None: + transcript = _transcript( + _segment(2, "Agents can edit the interface.", [("Agents", 0.99)]) + ) + amendment = asr_review.parse_amendments( + json.dumps( + { + "amendments": [ + { + "segment_id": 2, + "find": "Asians", + "replacement": "Agents", + "reason": "Context refers to software agents.", + } + ] + } + ) + ) + + with pytest.raises(OpenBBQError) as raised: + asr_review.merge_amendments(transcript, None, amendment) + + assert raised.value.code == "asr_amendment_find_missing" + + +def test_contextual_amendment_can_be_revised_without_leaving_conflicting_rules() -> ( + None +): + transcript = _transcript(_segment(3, "She mentioned Annie.", [("Annie.", 0.99)])) + first = asr_review.parse_amendments( + json.dumps( + { + "amendments": [ + { + "segment_id": 3, + "find": "Annie", + "replacement": "Andy", + "reason": "Initial contextual reading.", + } + ] + } + ) + ) + review, first_ids = asr_review.merge_amendments(transcript, None, first) + revised = asr_review.parse_amendments( + json.dumps( + { + "amendments": [ + { + "segment_id": 3, + "find": "Annie", + "replacement": "Annie Murphy", + "reason": "Later context gives the full confirmed name.", + } + ] + } + ) + ) + + review, revised_ids = asr_review.merge_amendments(transcript, review, revised) + + assert revised_ids == first_ids + assert len(review.decisions) == 1 + assert asr_review.corrector(review)(transcript.segments[0].text) == ( + "She mentioned Annie Murphy." + ) + + def test_replace_must_cover_uncertain_word_and_exist_in_segment() -> None: transcript = _transcript( - _segment(0, "Mew inspired me.", [("Mew", 0.4), ("inspired", 0.99), ("me.", 0.99)]) + _segment( + 0, "Mew inspired me.", [("Mew", 0.4), ("inspired", 0.99), ("me.", 0.99)] + ) ) with pytest.raises(OpenBBQError) as raised: @@ -467,7 +627,7 @@ def test_check_command_is_read_only_and_reports_next_action( payload = _payload(capsys) assert payload["ready"] is False assert payload["unresolved"] == 1 - assert payload["next"] == "openbbq asr batch --limit 20" + assert payload["next"] == f"openbbq asr batch --workspace {path} --limit 20" assert (path / "manifest.json").read_bytes() == manifest_before assert not ws.asr_review_path(path).exists() @@ -479,7 +639,13 @@ def test_batch_is_bounded_and_apply_unblocks_segment_with_correction( _segment( 0, "Thank you to Sean Hongxiu.", - [("Thank", 0.99), ("you", 0.99), ("to", 0.99), ("Sean", 0.38), ("Hongxiu.", 0.86)], + [ + ("Thank", 0.99), + ("you", 0.99), + ("to", 0.99), + ("Sean", 0.38), + ("Hongxiu.", 0.86), + ], ), _segment(1, "Heva!", [("Heva!", 0.36)]), ) @@ -535,3 +701,47 @@ def test_batch_is_bounded_and_apply_unblocks_segment_with_correction( ) _payload(capsys) assert ws.read_manifest(path).stages[Stage.SEGMENT].status is StageStatus.PENDING + + +def test_amend_command_persists_agent_found_high_confidence_correction( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + transcript = _transcript( + _segment( + 0, + "Here is my hot tick.", + [ + ("Here", 0.99), + ("is", 0.99), + ("my", 0.99), + ("hot", 0.99), + ("tick.", 0.99), + ], + ) + ) + path = _workspace(tmp_path, transcript) + amendments = tmp_path / "amendments.json" + amendments.write_text( + json.dumps( + { + "amendments": [ + { + "segment_id": 0, + "find": "hot tick", + "replacement": "hot take", + "reason": "The idiom is clear from the surrounding discussion.", + } + ] + } + ), + encoding="utf-8", + ) + + amend_cmd(_ctx(), amendments=str(amendments), workspace=str(path)) + + payload = _payload(capsys) + assert payload["applied"] == 1 + assert payload["ready"] is True + segment_cmd(_ctx(), workspace=str(path)) + _payload(capsys) + assert ws.read_cues(path / "cues.json").cues[0].source == "Here is my hot take." diff --git a/tests/test_burn.py b/tests/test_burn.py index df1aa1c..6c88f55 100644 --- a/tests/test_burn.py +++ b/tests/test_burn.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from datetime import datetime, timezone from pathlib import Path from types import SimpleNamespace @@ -97,7 +98,9 @@ def test_burn_uses_last_ass_export_and_records_stage( assert final.status is StageStatus.DONE assert final.artifact == "out/zh-burned.mp4" ws.require_fresh_artifact(path, path / final.artifact, Stage.BURN) - assert "openbbq --json status --workspace" in capsys.readouterr().err + captured = capsys.readouterr() + assert "openbbq --json status --workspace" in captured.err + assert json.loads(captured.out)["next"] == "openbbq delivery check" @pytest.mark.parametrize("changed", ["video", "source", "subtitle"]) diff --git a/tests/test_delivery.py b/tests/test_delivery.py index 9321882..4fc52d4 100644 --- a/tests/test_delivery.py +++ b/tests/test_delivery.py @@ -200,7 +200,7 @@ def _run_cli( return code, json.loads(captured.out) -def test_delivery_assessment_passes_only_complete_fresh_visual_workflow( +def test_delivery_assessment_passes_complete_fresh_workflow( tmp_path: Path, ) -> None: path = _workspace(tmp_path) @@ -217,7 +217,7 @@ def test_delivery_assessment_passes_only_complete_fresh_visual_workflow( assert status["delivery_issues"] == [] -def test_delivery_cli_visual_failure_is_nonzero_ready_false( +def test_delivery_cli_visual_failure_does_not_block_delivery( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], @@ -230,11 +230,21 @@ def test_delivery_cli_visual_failure_is_nonzero_ready_false( capsys, ) - assert code == 1 - assert payload["error"] == "delivery_not_ready" - assert payload["ready"] is False - issues = cast(list[dict[str, object]], payload["issues"]) - assert any(issue["code"] == "qa_visual_failed" for issue in issues) + assert code == 0 + assert payload["ok"] is True + assert payload["ready"] is True + assert "qa_visual" not in cast(dict[str, bool], payload["gates"]) + + +def test_delivery_does_not_require_rendered_frame_qa(tmp_path: Path) -> None: + path = _workspace(tmp_path) + ws.qa_path(path).unlink() + + assessment = assess_delivery(path, ws.read_manifest(path), lang="zh") + + assert assessment.ready is True + assert assessment.gates["qa_mechanical"] is True + assert "qa_visual" not in assessment.gates def test_delivery_cli_complete_workspace_exits_zero_ready_true( @@ -316,7 +326,6 @@ def test_delivery_detects_segment_stale_after_transcript_change(tmp_path: Path) ("relative_path", "expected_code"), [ (".openbbq/asr-review.json", "invalid_asr_review"), - (".openbbq/qa.json", "invalid_qa_report"), ], ) def test_delivery_turns_malformed_sidecars_into_ready_false_issues( @@ -333,6 +342,15 @@ def test_delivery_turns_malformed_sidecars_into_ready_false_issues( assert expected_code in {issue.code for issue in assessment.issues} +def test_delivery_ignores_optional_malformed_visual_qa(tmp_path: Path) -> None: + path = _workspace(tmp_path) + ws.qa_path(path).write_text("{}", encoding="utf-8") + + assessment = assess_delivery(path, ws.read_manifest(path), lang="zh") + + assert assessment.ready is True + + def test_delivery_incomplete_workspace_reports_actionable_gate_not_exception( tmp_path: Path, ) -> None: diff --git a/tests/test_export.py b/tests/test_export.py index 0251c7c..719e59e 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -342,6 +342,7 @@ def test_render_ass_reflows_two_target_lines_without_losing_text() -> None: assert "这个想法让我爱上\\N这门技艺" in out assert "Dialogue: 0,0:00:00.00,0:00:04.00,EN" in out + assert exp.is_bilingual_ass(out, cues, translation) is True # --- command shell ------------------------------------------------------------ diff --git a/tests/test_glossary.py b/tests/test_glossary.py index 7d3f851..dad4f2b 100644 --- a/tests/test_glossary.py +++ b/tests/test_glossary.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from datetime import datetime, timezone from pathlib import Path from types import SimpleNamespace @@ -9,17 +10,25 @@ import typer from pydantic import ValidationError -from openbbq.cli.commands.glossary import use +from openbbq.cli.commands.glossary import apply_patch, audit, suggest, use from openbbq.cli.commands.init import init from openbbq.cli.output import Output +from openbbq.core import asr_review as asr_reviewlib from openbbq.core import glossary as gl from openbbq.core import segment as seg from openbbq.core import workspace as ws from openbbq.errors import OpenBBQError from openbbq.schemas import ( ASRInfo, + AsrDecision, + AsrReview, Glossary, + Manifest, Segment, + Source, + Stage, + StageState, + StageStatus, Term, Transcript, Word, @@ -49,6 +58,10 @@ def _home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("OPENBBQ_HOME", str(tmp_path)) +def _payload(capsys: pytest.CaptureFixture[str]) -> dict[str, object]: + return cast(dict[str, object], json.loads(capsys.readouterr().out)) + + # --- schema ------------------------------------------------------------------- @@ -58,7 +71,9 @@ def test_term_defaults() -> None: def test_glossary_roundtrip_serializes_schema_alias() -> None: - g = Glossary(name="frieren", context="bg", terms=[Term(source="Frieren", target="芙莉莲")]) + g = Glossary( + name="frieren", context="bg", terms=[Term(source="Frieren", target="芙莉莲")] + ) dumped = g.model_dump_json() assert '"schema":"openbbq/glossary@1"' in dumped.replace(" ", "") assert Glossary.model_validate_json(dumped) == g @@ -66,13 +81,17 @@ def test_glossary_roundtrip_serializes_schema_alias() -> None: def test_glossary_forbids_unknown_keys() -> None: with pytest.raises(ValidationError): - Glossary.model_validate({"schema": "openbbq/glossary@1", "name": "x", "bogus": 1}) + Glossary.model_validate( + {"schema": "openbbq/glossary@1", "name": "x", "bogus": 1} + ) # --- library ------------------------------------------------------------------ -def test_scaffold_then_load_roundtrip(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_scaffold_then_load_roundtrip( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: _home(tmp_path, monkeypatch) path = gl.scaffold("frieren", context="奇幻动画") assert path.is_file() and path.name == "frieren.json" @@ -80,14 +99,18 @@ def test_scaffold_then_load_roundtrip(tmp_path: Path, monkeypatch: pytest.Monkey assert g.name == "frieren" and g.context == "奇幻动画" and g.terms == [] -def test_load_missing_is_not_found(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_load_missing_is_not_found( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: _home(tmp_path, monkeypatch) with pytest.raises(OpenBBQError) as exc: gl.load("nope") assert exc.value.code == "glossary_not_found" -def test_scaffold_existing_is_exists_error(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_scaffold_existing_is_exists_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: _home(tmp_path, monkeypatch) gl.scaffold("frieren") with pytest.raises(OpenBBQError) as exc: @@ -95,7 +118,9 @@ def test_scaffold_existing_is_exists_error(tmp_path: Path, monkeypatch: pytest.M assert exc.value.code == "glossary_exists" -def test_load_malformed_is_invalid(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_load_malformed_is_invalid( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: _home(tmp_path, monkeypatch) p = gl.glossary_path("broken") p.parent.mkdir(parents=True, exist_ok=True) @@ -105,7 +130,9 @@ def test_load_malformed_is_invalid(tmp_path: Path, monkeypatch: pytest.MonkeyPat assert exc.value.code == "invalid_glossary" -def test_list_names_empty_then_sorted(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_list_names_empty_then_sorted( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: _home(tmp_path, monkeypatch) assert gl.list_names() == [] gl.scaffold("zeta") @@ -117,13 +144,66 @@ def test_load_optional_none_passes_through() -> None: assert gl.load_optional(None) is None +def test_term_patch_upserts_aliases_without_erasing_unspecified_translation() -> None: + original = Glossary( + name="agents", + terms=[Term(source="Andy Matuschak", target="安迪·马图沙克")], + ) + patches = gl.parse_term_patch( + json.dumps( + { + "terms": [ + { + "source": "Andy Matuschak", + "aliases": ["Annie Matushak", "Annie Matushak"], + "note": "researcher; recurring ASR error", + }, + { + "source": "Geoffrey Litt", + "aliases": ["Jeffrey Litt"], + "keep": True, + }, + ] + } + ) + ) + + updated, report = gl.upsert_terms(original, patches) + + assert report.added == ("Geoffrey Litt",) + assert report.updated == ("Andy Matuschak",) + assert report.aliases_added == 2 + assert updated.terms[0].target == "安迪·马图沙克" + assert updated.terms[0].aliases == ["Annie Matushak"] + + +def test_term_patch_rejects_alias_owned_by_another_term() -> None: + original = Glossary( + name="agents", + terms=[Term(source="Agents"), Term(source="Asians")], + ) + patches = gl.parse_term_patch( + json.dumps({"terms": [{"source": "Agents", "aliases": ["Asians"]}]}) + ) + + with pytest.raises(OpenBBQError) as raised: + gl.upsert_terms(original, patches) + + assert raised.value.code == "glossary_form_conflict" + + # --- touchpoint 1: bias ------------------------------------------------------- def test_bias_terms_dedupes_orders_and_strips() -> None: g = Glossary( name="g", - terms=[Term(source="Frieren"), Term(source=" "), Term(source="Himmel"), Term(source="Frieren")], + terms=[ + Term(source="Frieren"), + Term(source=" "), + Term(source="Himmel"), + Term(source="Frieren"), + ], ) assert gl.bias_terms(g) == ["Frieren", "Himmel"] @@ -137,7 +217,9 @@ def test_corrector_identity_without_glossary() -> None: def test_corrector_replaces_alias_case_insensitively() -> None: - fix = gl.corrector(Glossary(name="g", terms=[Term(source="Frieren", aliases=["Freerun"])])) + fix = gl.corrector( + Glossary(name="g", terms=[Term(source="Frieren", aliases=["Freerun"])]) + ) assert fix("and freerun smiled") == "and Frieren smiled" @@ -156,10 +238,36 @@ def test_corrector_multiword_alias_wins_over_shorter() -> None: assert fix("the Free run") == "the Frieren" +def test_correction_tracker_reports_binding_effect_and_alias_count() -> None: + tracker = gl.CorrectionTracker( + Glossary( + name="agents", + terms=[ + Term(source="Andy Matuschak", aliases=["Annie Matushak"]), + Term(source="Notion"), + ], + ) + ) + + assert tracker("Annie Matushak discussed Notion with Annie Matushak.") == ( + "Andy Matuschak discussed Notion with Andy Matuschak." + ) + assert tracker.matched_terms == {"Andy Matuschak", "Notion"} + assert [ + (item.source, item.alias, item.count) for item in tracker.alias_applications + ] == [("Andy Matuschak", "Annie Matushak", 2)] + + def test_correction_applies_in_build_cues() -> None: g = Glossary(name="g", terms=[Term(source="Frieren", aliases=["Freerun"])]) t = _transcript( - Segment(id=0, start=0, end=2, text="x", words=[W("And", 0, 0.3), W("Freerun.", 0.3, 1.2)]) + Segment( + id=0, + start=0, + end=2, + text="x", + words=[W("And", 0, 0.3), W("Freerun.", 0.3, 1.2)], + ) ) outcome = seg.build_cues(t, EN, gl.corrector(g)) assert outcome.cues[0].source == "And Frieren." @@ -171,7 +279,10 @@ def test_correction_applies_in_build_cues() -> None: def _proper_noun_transcript() -> Transcript: segs = [ Segment( - id=i, start=i, end=i + 1, text="And Freerun smiled at the village.", + id=i, + start=i, + end=i + 1, + text="And Freerun smiled at the village.", words=[ W("And", i, i + 0.2, 0.99), W("Freerun", i + 0.2, i + 0.6, 0.41), @@ -221,15 +332,21 @@ def _local_video(tmp_path: Path) -> str: return str(v) -def test_init_binds_existing_glossary(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_init_binds_existing_glossary( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: _home(tmp_path, monkeypatch) gl.scaffold("frieren") wsdir = tmp_path / "ws" - init(_ctx(), source=_local_video(tmp_path), workspace=str(wsdir), glossary="frieren") + init( + _ctx(), source=_local_video(tmp_path), workspace=str(wsdir), glossary="frieren" + ) assert ws.read_manifest(wsdir).glossary == "frieren" -def test_init_rejects_missing_glossary(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_init_rejects_missing_glossary( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: _home(tmp_path, monkeypatch) with pytest.raises(OpenBBQError) as exc: init( @@ -241,10 +358,179 @@ def test_init_rejects_missing_glossary(tmp_path: Path, monkeypatch: pytest.Monke assert exc.value.code == "glossary_not_found" -def test_glossary_use_rebinds_manifest(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_glossary_use_rebinds_manifest( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: _home(tmp_path, monkeypatch) gl.scaffold("frieren") wsdir = tmp_path / "ws" init(_ctx(), source=_local_video(tmp_path), workspace=str(wsdir), glossary=None) use(_ctx(), name="frieren", workspace=str(wsdir)) assert ws.read_manifest(wsdir).glossary == "frieren" + + +def _audit_workspace( + tmp_path: Path, transcript: Transcript, glossary: str | None = None +) -> Path: + path = tmp_path / "audit-ws" + path.mkdir() + source = tmp_path / "audio.wav" + source.write_bytes(b"audio") + (path / "transcript.json").write_text( + transcript.model_dump_json(), encoding="utf-8" + ) + ws.write_manifest( + path, + Manifest( + created_at=datetime.now(timezone.utc), + source=Source(type="local_audio", ref=str(source)), + glossary=glossary, + stages={ + Stage.TRANSCRIBE: StageState( + status=StageStatus.DONE, + artifact="transcript.json", + ) + }, + ), + ) + return path + + +def test_glossary_audit_returns_all_context_even_for_high_confidence_words( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + transcript = _transcript( + Segment( + id=0, + start=0, + end=1, + text="We discussed software agents.", + words=[W("agents.", 0, 1, 0.99)], + ), + Segment( + id=1, + start=1, + end=2, + text="Here is my hot tick.", + words=[W("tick.", 1, 2, 0.99)], + ), + Segment( + id=2, + start=2, + end=3, + text="That is the main point.", + words=[W("point.", 2, 3, 0.99)], + ), + ) + path = _audit_workspace(tmp_path, transcript) + review = AsrReview( + transcript_hash=asr_reviewlib.transcript_hash(transcript), + max_prob=0.5, + decisions={ + "m:s1:test": AsrDecision( + action="replace", + find="hot tick", + replacement="hot take", + reason="Context indicates the idiom hot take.", + ) + }, + ) + ws.write_asr_review(path, review) + ws.write_reference_caption( + path, + "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nHere is my hot take.\n", + ) + + audit(_ctx(), workspace=str(path), offset=1, limit=1) + + payload = _payload(capsys) + item = cast(list[dict[str, object]], payload["items"])[0] + assert item["source"] == "Here is my hot take." + assert item["raw_source"] == "Here is my hot tick." + assert item["previous"] == "We discussed software agents." + assert item["next_segment"] == "That is the main point." + assert item["min_prob"] == 0.99 + assert item["reference_caption"] == "Here is my hot take." + + +def test_glossary_suggest_uses_resolved_asr_text( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + transcript = _transcript( + *[ + Segment( + id=index, + start=index, + end=index + 1, + text="Annie Matushak explained it.", + words=[ + W("Annie", index, index + 0.3, 0.2), + W("Matushak", index + 0.3, index + 0.8, 0.2), + ], + ) + for index in range(2) + ] + ) + path = _audit_workspace(tmp_path, transcript) + review = AsrReview( + transcript_hash=asr_reviewlib.transcript_hash(transcript), + max_prob=0.5, + decisions={ + "m:s0:test": AsrDecision( + action="replace", + find="Annie Matushak", + replacement="Andy Matuschak", + reason="The researcher name is confirmed by context.", + ) + }, + ) + ws.write_asr_review(path, review) + + suggest(_ctx(), workspace=str(path)) + + candidates = cast(list[dict[str, object]], _payload(capsys)["candidates"]) + assert all(item["surface"] not in {"Annie", "Matushak"} for item in candidates) + + +def test_glossary_apply_atomically_updates_bound_library_and_invalidates_segment( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + _home(tmp_path / "home", monkeypatch) + gl.scaffold("agents") + transcript = _transcript( + Segment(id=0, start=0, end=1, text="A hot tick.", words=[W("tick.", 0, 1)]) + ) + path = _audit_workspace(tmp_path, transcript, glossary="agents") + manifest = ws.read_manifest(path) + manifest.stages[Stage.SEGMENT] = StageState( + status=StageStatus.DONE, + artifact="cues.json", + ) + ws.write_manifest(path, manifest) + patch = tmp_path / "terms.json" + patch.write_text( + json.dumps({"terms": [{"source": "hot take", "aliases": ["hot tick"]}]}), + encoding="utf-8", + ) + + apply_patch(_ctx(), changes=str(patch), workspace=str(path)) + + payload = _payload(capsys) + assert payload["added"] == ["hot take"] + assert gl.load("agents").terms[0].aliases == ["hot tick"] + assert ws.read_manifest(path).stages[Stage.SEGMENT].status is StageStatus.PENDING + + manifest = ws.read_manifest(path) + manifest.stages[Stage.SEGMENT] = StageState( + status=StageStatus.DONE, + artifact="cues.json", + ) + ws.write_manifest(path, manifest) + apply_patch(_ctx(), changes=str(patch), workspace=str(path)) + + payload = _payload(capsys) + assert payload["unchanged"] == ["hot take"] + assert payload.get("workspace_invalidated") is None + assert ws.read_manifest(path).stages[Stage.SEGMENT].status is StageStatus.DONE diff --git a/tests/test_qa.py b/tests/test_qa.py index 17e5610..e34efd8 100644 --- a/tests/test_qa.py +++ b/tests/test_qa.py @@ -298,7 +298,7 @@ def test_check_is_read_only_and_attestation_becomes_stale_with_video( assert "artifact_content_changed" in issues -def test_failed_attestation_requires_structured_issue_and_suggests_compact_preset( +def test_failed_attestation_requires_structured_issue_without_remediation_loop( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], @@ -330,9 +330,7 @@ def test_failed_attestation_requires_structured_issue_and_suggests_compact_prese assert payload["visual_issues"] == [ {"code": "lower_third_conflict", "cue_ids": []} ] - next_step = payload["next"] - assert isinstance(next_step, str) - assert "fansub-compact" in next_step + assert payload.get("next") is None def test_failed_rerender_keeps_previous_qa_report_valid( diff --git a/tests/test_segment.py b/tests/test_segment.py index 86bde7e..1cc76f5 100644 --- a/tests/test_segment.py +++ b/tests/test_segment.py @@ -21,6 +21,8 @@ StageState, StageStatus, Transcript, + Glossary, + Term, Word, ) @@ -73,10 +75,7 @@ def test_apply_overrides_only_touches_given_fields() -> None: def test_wrap_feasible_rejects_word_longer_than_line() -> None: # total chars under per_line*lines, but one word can't fit a line - assert ( - seg.wrap_feasible([W("x" * (EN.max_chars_per_line + 1), 0, 1)], EN) - is False - ) + assert seg.wrap_feasible([W("x" * (EN.max_chars_per_line + 1), 0, 1)], EN) is False def test_wrap_feasible_packs_short_words() -> None: @@ -187,18 +186,22 @@ def _transcript(*segments: Segment, language: str = "en") -> Transcript: return Transcript( language=language, duration=10.0, - asr=ASRInfo( - backend="test", model="t", created_at=datetime.now(timezone.utc) - ), + asr=ASRInfo(backend="test", model="t", created_at=datetime.now(timezone.utc)), segments=list(segments), ) def test_finalize_assigns_1_based_ids_and_source() -> None: outcome = seg.build_cues( - _transcript(Segment(id=0, start=0, end=2, text="x", words=[ - W("Hello", 0, 0.6), W("there.", 0.6, 1.6) - ])), + _transcript( + Segment( + id=0, + start=0, + end=2, + text="x", + words=[W("Hello", 0, 0.6), W("there.", 0.6, 1.6)], + ) + ), EN, ) assert [c.id for c in outcome.cues] == [1] @@ -227,8 +230,12 @@ def test_build_cues_keeps_screen_fit_sentence_whole() -> None: def test_finalize_enforces_min_gap_by_trimming_previous_end() -> None: - words = [W("Hi", 0, 0.4), W("there.", 0.4, 1.0), - W("Bye", 1.0, 1.4), W("now.", 1.4, 2.0)] + words = [ + W("Hi", 0, 0.4), + W("there.", 0.4, 1.0), + W("Bye", 1.0, 1.4), + W("now.", 1.4, 2.0), + ] outcome = seg.build_cues( _transcript(Segment(id=0, start=0, end=2, text="x", words=words)), EN ) @@ -253,16 +260,17 @@ def test_finalize_clamps_preexisting_asr_word_overlap() -> None: assert [cue.source for cue in outcome.cues] == ["Alpha bravo.", "Charlie delta."] assert all(cue.start <= cue.end for cue in outcome.cues) assert all( - left.end <= right.start - for left, right in zip(outcome.cues, outcome.cues[1:]) + left.end <= right.start for left, right in zip(outcome.cues, outcome.cues[1:]) ) def test_finalize_counts_over_cps() -> None: # 29 chars in a sub-second cue, extended to min_dur 1.0 -> 29 cps > 21 (en) words = [ - W("hello", 0, 0.1), W("there", 0.1, 0.2), - W("wonderful", 0.2, 0.3), W("people.", 0.3, 0.5), + W("hello", 0, 0.1), + W("there", 0.1, 0.2), + W("wonderful", 0.2, 0.3), + W("people.", 0.3, 0.5), ] outcome = seg.build_cues( _transcript(Segment(id=0, start=0, end=0.5, text="x", words=words)), EN @@ -272,9 +280,15 @@ def test_finalize_counts_over_cps() -> None: def test_build_cues_counts_over_cap_for_unsplittable_word() -> None: outcome = seg.build_cues( - _transcript(Segment(id=0, start=0, end=1, text="x", words=[ - W("supercalifragilisticexpialidocious" * 3, 0, 1) - ])), + _transcript( + Segment( + id=0, + start=0, + end=1, + text="x", + words=[W("supercalifragilisticexpialidocious" * 3, 0, 1)], + ) + ), EN, ) assert outcome.over_cap == 1 @@ -363,9 +377,13 @@ def test_segment_invalid_transcript_errors(tmp_path) -> None: def test_segment_writes_cues_and_records_stage(tmp_path) -> None: path, manifest = _workspace(tmp_path) transcript = _transcript( - Segment(id=0, start=0, end=1.6, text="Hello there.", words=[ - W("Hello", 0, 0.6), W("there.", 0.6, 1.6) - ]) + Segment( + id=0, + start=0, + end=1.6, + text="Hello there.", + words=[W("Hello", 0, 0.6), W("there.", 0.6, 1.6)], + ) ) _with_transcript(path, manifest, transcript) @@ -379,3 +397,83 @@ def test_segment_writes_cues_and_records_stage(tmp_path) -> None: assert doc.source_lang == "en" assert [c.id for c in doc.cues] == [1] assert ws.read_manifest(path).stages[Stage.SEGMENT].status is StageStatus.DONE + + +def test_segment_reports_glossary_matches_alias_corrections_and_no_effect( + tmp_path, monkeypatch, capsys +) -> None: + monkeypatch.setenv("OPENBBQ_HOME", str(tmp_path / "home")) + from openbbq.core import glossary as glossarylib + + glossarylib.save( + Glossary( + name="agents", + terms=[ + Term(source="hot take", aliases=["hot tick"]), + Term(source="Notion"), + ], + ) + ) + path, manifest = _workspace(tmp_path) + manifest.glossary = "agents" + transcript = _transcript( + Segment( + id=0, + start=0, + end=2, + text="A hot tick in Notion.", + words=[ + W("A", 0, 0.2), + W("hot", 0.2, 0.6), + W("tick", 0.6, 1.0), + W("in", 1.0, 1.2), + W("Notion.", 1.2, 2.0), + ], + ) + ) + _with_transcript(path, manifest, transcript) + + segment(_ctx(), workspace=str(path)) + + import json + + payload = json.loads(capsys.readouterr().out) + assert payload["glossary_matched_terms"] == ["Notion", "hot take"] + assert payload["glossary_aliases_applied"] == [ + {"source": "hot take", "alias": "hot tick", "count": 1} + ] + assert payload["glossary_no_effect"] is False + assert ws.read_cues(path / "cues.json").cues[0].source == "A hot take in Notion." + + +def test_segment_reports_bound_glossary_with_no_effect( + tmp_path, monkeypatch, capsys +) -> None: + monkeypatch.setenv("OPENBBQ_HOME", str(tmp_path / "home")) + from openbbq.core import glossary as glossarylib + + glossarylib.save(Glossary(name="unrelated", terms=[Term(source="Frieren")])) + path, manifest = _workspace(tmp_path) + manifest.glossary = "unrelated" + _with_transcript( + path, + manifest, + _transcript( + Segment( + id=0, + start=0, + end=1, + text="Hello there.", + words=[W("Hello", 0, 0.5), W("there.", 0.5, 1)], + ) + ), + ) + + segment(_ctx(), workspace=str(path)) + + import json + + payload = json.loads(capsys.readouterr().out) + assert payload["glossary_matched_terms"] == [] + assert payload["glossary_aliases_applied"] == [] + assert payload["glossary_no_effect"] is True diff --git a/uv.lock b/uv.lock index 6520659..c6ddb68 100644 --- a/uv.lock +++ b/uv.lock @@ -302,7 +302,7 @@ wheels = [ [[package]] name = "openbbq" -version = "0.0.7" +version = "0.0.8" source = { editable = "." } dependencies = [ { name = "pydantic" },