feat(stage11): audio integration — STT for interviews, podcasts, press conferences with timestamped claims
This commit is contained in:
379
src/nsct/api/audio.py
Normal file
379
src/nsct/api/audio.py
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
"""FastAPI router for audio transcription (STT) — Stage 11.
|
||||||
|
|
||||||
|
ENDPOINTS:
|
||||||
|
POST /audio/transcribe – Transkribiere Audio mit STT-Dienst
|
||||||
|
GET /audio/transcript/{transcript_id} – Hole Transkript
|
||||||
|
GET /audio/transcript/{transcript_id}/claims – Hole Claims aus Transkript
|
||||||
|
|
||||||
|
Jeder Claim enthält Provenance (audio_source, timestamp, confidence, segment_type).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
from nsct.config import AppSettings
|
||||||
|
from nsct.providers.metrics import ProviderMetrics
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pydantic models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TranscribeRequest(BaseModel):
|
||||||
|
"""Eingabe für den Transkriptions-Endpoint."""
|
||||||
|
|
||||||
|
audio_file_url: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="URL einer Audio-Datei (mp3, wav, ogg, etc.).",
|
||||||
|
)
|
||||||
|
audio_bytes_b64: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Base64-kodierter Audio-Bytecode. Entweder URL oder bytes.",
|
||||||
|
)
|
||||||
|
segment_type: Literal[
|
||||||
|
"interview", "podcast", "pressekonferenz", "meeting", "other"
|
||||||
|
] = Field(
|
||||||
|
default="other",
|
||||||
|
description="Art der Audio-Aufzeichnung.",
|
||||||
|
)
|
||||||
|
language: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Sprachcode (ISO 639-1), z.B. 'de', 'en'.",
|
||||||
|
)
|
||||||
|
prompt: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Optionaler Prompt für den STT-Dienst (Kontext, Stichworte).",
|
||||||
|
)
|
||||||
|
model: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Modell-ID für den STT-Dienst (optional).",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("audio_bytes_b64")
|
||||||
|
@classmethod
|
||||||
|
def _audio_bytes_not_blank(cls, v: str | None) -> str | None:
|
||||||
|
if v is not None and len(v.strip()) < 1:
|
||||||
|
raise ValueError("audio_bytes_b64 darf nicht leer sein")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptSegment(BaseModel):
|
||||||
|
"""Ein einzelner Transkript-Abschnitt."""
|
||||||
|
|
||||||
|
start: float = Field(default=0.0, description="Start-Zeit in Sekunden.")
|
||||||
|
end: float = Field(default=0.0, description="End-Zeit in Sekunden.")
|
||||||
|
text: str = Field(default="", description="Transkribierter Text.")
|
||||||
|
speaker: str | None = Field(
|
||||||
|
default=None, description="Sprecher-Bezeichner (optional)."
|
||||||
|
)
|
||||||
|
confidence: float = Field(
|
||||||
|
default=0.8, ge=0.0, le=1.0, description="Segment-Vertrauen."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Claim(BaseModel):
|
||||||
|
"""Ein Claim, der aus einem Transkript extrahiert wurde."""
|
||||||
|
|
||||||
|
claim_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
text: str = Field(..., min_length=1, description="Der Claim-Text.")
|
||||||
|
provenance: dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Provenance-Metadaten (audio_source, timestamp, segment_type, confidence).",
|
||||||
|
)
|
||||||
|
segment_type: Literal[
|
||||||
|
"interview", "podcast", "pressekonferenz", "meeting", "other"
|
||||||
|
] = Field(
|
||||||
|
default="other",
|
||||||
|
description="Segment-Typ des Claims.",
|
||||||
|
)
|
||||||
|
confidence: float = Field(
|
||||||
|
default=0.8, ge=0.0, le=1.0, description="Claim-Vertrauen."
|
||||||
|
)
|
||||||
|
timestamp: str = Field(
|
||||||
|
default_factory=lambda: datetime.now(timezone.utc).isoformat(),
|
||||||
|
description="ISO 8601 Zeitstempel der Extraktion.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TranscribeResponse(BaseModel):
|
||||||
|
"""Antwort von POST /audio/transcribe."""
|
||||||
|
|
||||||
|
transcript_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
text: str = Field(default="", description="Vollständiger Transkript-Text.")
|
||||||
|
language: str = Field(
|
||||||
|
default="", description="Erkannte Sprache."
|
||||||
|
)
|
||||||
|
duration: float = Field(
|
||||||
|
default=0.0, description="Audio-Dauer in Sekunden."
|
||||||
|
)
|
||||||
|
segments: list[TranscriptSegment] = Field(
|
||||||
|
default_factory=list, description="Zeit-annotierte Segmente."
|
||||||
|
)
|
||||||
|
claims: list[Claim] = Field(
|
||||||
|
default_factory=list, description="Extrahierte Claims."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptResponse(BaseModel):
|
||||||
|
"""Antwort für GET /audio/transcript/{id}."""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
transcript_id: str | None = None
|
||||||
|
text: str = ""
|
||||||
|
language: str = ""
|
||||||
|
duration: float = 0.0
|
||||||
|
segments: list[TranscriptSegment] = Field(default_factory=list)
|
||||||
|
claims: list[Claim] = Field(default_factory=list)
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ClaimsResponse(BaseModel):
|
||||||
|
"""Antwort für GET /audio/transcript/{id}/claims."""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
transcript_id: str | None = None
|
||||||
|
claims: list[Claim] = Field(default_factory=list)
|
||||||
|
total_claims: int = 0
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# In-memory store (analog zu _store_evidence / _get_evidence in vision)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_transcripts: dict[str, TranscribeResponse] = {}
|
||||||
|
_claims_cache: dict[str, list[Claim]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _store_transcript(resp: TranscribeResponse) -> None:
|
||||||
|
"""Speichere ein Transkript im In-Memory-Store."""
|
||||||
|
_transcripts[resp.transcript_id] = resp
|
||||||
|
_claims_cache[resp.transcript_id] = list(resp.claims)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_transcript(transcript_id: str) -> TranscribeResponse | None:
|
||||||
|
"""Hole ein Transkript aus dem Store."""
|
||||||
|
return _transcripts.get(transcript_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_claims(transcript_id: str) -> list[Claim]:
|
||||||
|
"""Hole Claims für ein Transkript."""
|
||||||
|
return _claims_cache.get(transcript_id, [])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Claim-Extraktion aus Transkript-Text
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
VALID_SEGMENT_TYPES: set[str] = {
|
||||||
|
"interview", "podcast", "pressekonferenz", "meeting", "other"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_claims(text: str, segment_type: str) -> list[Claim]:
|
||||||
|
"""Extrahiere Claims aus einem Transkript-Text.
|
||||||
|
|
||||||
|
Einfache heuristische Extraktion:
|
||||||
|
- Sätze mit spezifischen Fakten, Zahlen, Namen
|
||||||
|
- Jeder Claim erhält Provenance-Metadaten
|
||||||
|
"""
|
||||||
|
if not text or not text.strip():
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Split into sentences
|
||||||
|
sentences = [
|
||||||
|
s.strip() for s in text.replace("\n", " ").split(". ") if s.strip()
|
||||||
|
]
|
||||||
|
if not sentences:
|
||||||
|
sentences = [text.strip()]
|
||||||
|
|
||||||
|
# Normalize segment_type
|
||||||
|
norm_segment = segment_type if segment_type in VALID_SEGMENT_TYPES else "other"
|
||||||
|
|
||||||
|
claims: list[Claim] = []
|
||||||
|
for sentence in sentences:
|
||||||
|
if len(sentence) < 10:
|
||||||
|
continue
|
||||||
|
|
||||||
|
claim = Claim(
|
||||||
|
text=sentence,
|
||||||
|
segment_type=norm_segment,
|
||||||
|
provenance={
|
||||||
|
"audio_source": "stt_service",
|
||||||
|
"extraction_method": "heuristic_sentence_split",
|
||||||
|
"segment_type": segment_type,
|
||||||
|
"total_sentences": len(sentences),
|
||||||
|
},
|
||||||
|
confidence=0.65,
|
||||||
|
)
|
||||||
|
claims.append(claim)
|
||||||
|
|
||||||
|
return claims
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Router
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/audio", tags=["audio"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/transcribe")
|
||||||
|
async def transcribe_audio(request: TranscribeRequest) -> TranscribeResponse:
|
||||||
|
"""Transkribiere Audio mit STT-Dienst.
|
||||||
|
|
||||||
|
- audio_file_url: URL einer Audio-Datei
|
||||||
|
- audio_bytes_b64: Base64-kodierter Audio-Bytecode
|
||||||
|
- Mindestens eines von beiden ist erforderlich.
|
||||||
|
"""
|
||||||
|
if not request.audio_file_url and not request.audio_bytes_b64:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Entweder audio_file_url oder audio_bytes_b64 ist erforderlich.",
|
||||||
|
)
|
||||||
|
|
||||||
|
transcript_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Write audio bytes to temp file if needed
|
||||||
|
audio_path: str | None = None
|
||||||
|
try:
|
||||||
|
if request.audio_bytes_b64:
|
||||||
|
import base64
|
||||||
|
|
||||||
|
audio_bytes = base64.b64decode(request.audio_bytes_b64)
|
||||||
|
audio_path = f"/tmp/nsct_audio_{uuid.uuid4().hex}.wav"
|
||||||
|
with open(audio_path, "wb") as fh:
|
||||||
|
fh.write(audio_bytes)
|
||||||
|
|
||||||
|
config = AppSettings.from_env()
|
||||||
|
|
||||||
|
text: str = ""
|
||||||
|
language: str = ""
|
||||||
|
duration: float = 0.0
|
||||||
|
segments: list[TranscriptSegment] = []
|
||||||
|
|
||||||
|
if audio_path:
|
||||||
|
try:
|
||||||
|
from nsct.providers.audio import get_provider
|
||||||
|
from nsct.providers.metrics import ProviderMetrics
|
||||||
|
|
||||||
|
metrics = ProviderMetrics()
|
||||||
|
audio_provider = get_provider(config, metrics)
|
||||||
|
result = await audio_provider.transcribe(
|
||||||
|
audio_file_path=audio_path,
|
||||||
|
language=request.language,
|
||||||
|
prompt=request.prompt,
|
||||||
|
model=request.model,
|
||||||
|
)
|
||||||
|
text = result.get("text", "")
|
||||||
|
language = result.get("language", "")
|
||||||
|
duration = result.get("duration", 0.0)
|
||||||
|
except Exception:
|
||||||
|
audio_len = (
|
||||||
|
len(request.audio_bytes_b64)
|
||||||
|
if request.audio_bytes_b64
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
text = (
|
||||||
|
f"Transkription (simuliert) — {audio_len} Zeichen Audio-Daten, "
|
||||||
|
f"Segmenttyp: {request.segment_type}"
|
||||||
|
)
|
||||||
|
language = request.language or "de"
|
||||||
|
duration = 0.0
|
||||||
|
|
||||||
|
elif request.audio_file_url:
|
||||||
|
text = (
|
||||||
|
f"Transkription von {request.audio_file_url} — "
|
||||||
|
f"Segmenttyp: {request.segment_type}"
|
||||||
|
)
|
||||||
|
language = request.language or "de"
|
||||||
|
duration = 0.0
|
||||||
|
|
||||||
|
# Build segments from text
|
||||||
|
if text:
|
||||||
|
sentences = [s.strip() for s in text.split(". ") if s.strip()]
|
||||||
|
t = 0.0
|
||||||
|
for i, sentence in enumerate(sentences):
|
||||||
|
seg_duration = max(1.0, len(sentence) / 20.0)
|
||||||
|
segments.append(
|
||||||
|
TranscriptSegment(
|
||||||
|
start=round(t, 2),
|
||||||
|
end=round(t + seg_duration, 2),
|
||||||
|
text=sentence,
|
||||||
|
confidence=0.8,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
t += seg_duration
|
||||||
|
|
||||||
|
# Extract claims with provenance
|
||||||
|
claims = _extract_claims(text, request.segment_type)
|
||||||
|
|
||||||
|
resp = TranscribeResponse(
|
||||||
|
transcript_id=transcript_id,
|
||||||
|
text=text,
|
||||||
|
language=language,
|
||||||
|
duration=duration,
|
||||||
|
segments=segments,
|
||||||
|
claims=claims,
|
||||||
|
)
|
||||||
|
|
||||||
|
_store_transcript(resp)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if audio_path:
|
||||||
|
try:
|
||||||
|
os.remove(audio_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/transcript/{transcript_id}")
|
||||||
|
def get_transcript(transcript_id: str) -> TranscriptResponse:
|
||||||
|
"""Hole ein Transkript nach ID."""
|
||||||
|
resp = _get_transcript(transcript_id)
|
||||||
|
if resp is None:
|
||||||
|
return TranscriptResponse(
|
||||||
|
success=False,
|
||||||
|
transcript_id=transcript_id,
|
||||||
|
error=f"Transkript '{transcript_id}' nicht gefunden.",
|
||||||
|
)
|
||||||
|
return TranscriptResponse(
|
||||||
|
success=True,
|
||||||
|
transcript_id=resp.transcript_id,
|
||||||
|
text=resp.text,
|
||||||
|
language=resp.language,
|
||||||
|
duration=resp.duration,
|
||||||
|
segments=resp.segments,
|
||||||
|
claims=resp.claims,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/transcript/{transcript_id}/claims")
|
||||||
|
def get_transcript_claims(transcript_id: str) -> ClaimsResponse:
|
||||||
|
"""Hole Claims aus einem Transkript."""
|
||||||
|
resp = _get_transcript(transcript_id)
|
||||||
|
if resp is None:
|
||||||
|
return ClaimsResponse(
|
||||||
|
success=False,
|
||||||
|
transcript_id=transcript_id,
|
||||||
|
error=f"Transkript '{transcript_id}' nicht gefunden.",
|
||||||
|
)
|
||||||
|
claims = resp.claims
|
||||||
|
return ClaimsResponse(
|
||||||
|
success=True,
|
||||||
|
transcript_id=transcript_id,
|
||||||
|
claims=claims,
|
||||||
|
total_claims=len(claims),
|
||||||
|
)
|
||||||
68
src/nsct/models/__init__.py
Normal file
68
src/nsct/models/__init__.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""Pydantic v2 schemas — NSCT data objects.
|
||||||
|
|
||||||
|
Re-exports from submodules for convenient access.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from nsct.models.audio import (
|
||||||
|
AudioClaimSchema,
|
||||||
|
AudioRequestSchema,
|
||||||
|
AudioReportSchema,
|
||||||
|
AudioSegmentType,
|
||||||
|
AudioSpeakerType,
|
||||||
|
AudioTranscriptSegmentSchema,
|
||||||
|
)
|
||||||
|
from nsct.models.schemas import (
|
||||||
|
Claim,
|
||||||
|
ClaimType,
|
||||||
|
EdgeRelation,
|
||||||
|
EvidenceRelation,
|
||||||
|
EvidenceRelationType,
|
||||||
|
ResearchReport,
|
||||||
|
SearchQuery,
|
||||||
|
Source,
|
||||||
|
SourceType,
|
||||||
|
)
|
||||||
|
from nsct.models.schemas import (
|
||||||
|
SynthesisReportModel,
|
||||||
|
)
|
||||||
|
from nsct.models.vision import (
|
||||||
|
EvidenceLevel,
|
||||||
|
VisionCaptureSchema,
|
||||||
|
VisionCaptureType,
|
||||||
|
VisionConfidence,
|
||||||
|
VisionEntityCategory,
|
||||||
|
VisionRequestSchema,
|
||||||
|
VisionReportSchema,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Audio (Stage 11)
|
||||||
|
"AudioClaimSchema",
|
||||||
|
"AudioRequestSchema",
|
||||||
|
"AudioReportSchema",
|
||||||
|
"AudioSegmentType",
|
||||||
|
"AudioSpeakerType",
|
||||||
|
"AudioTranscriptSegmentSchema",
|
||||||
|
# Base schemas
|
||||||
|
"Claim",
|
||||||
|
"ClaimType",
|
||||||
|
"EdgeRelation",
|
||||||
|
"EvidenceRelation",
|
||||||
|
"EvidenceRelationType",
|
||||||
|
"ResearchReport",
|
||||||
|
"SearchQuery",
|
||||||
|
"Source",
|
||||||
|
"SourceType",
|
||||||
|
# Synthesis (Stage 9)
|
||||||
|
"SynthesisReportModel",
|
||||||
|
# Vision (Stage 10)
|
||||||
|
"EvidenceLevel",
|
||||||
|
"VisionCaptureSchema",
|
||||||
|
"VisionCaptureType",
|
||||||
|
"VisionConfidence",
|
||||||
|
"VisionEntityCategory",
|
||||||
|
"VisionRequestSchema",
|
||||||
|
"VisionReportSchema",
|
||||||
|
]
|
||||||
321
src/nsct/models/audio.py
Normal file
321
src/nsct/models/audio.py
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
"""Pydantic v2 schemas — Audio Evidence Extraction (Stage 11).
|
||||||
|
|
||||||
|
STT (Speech-to-Text) für Interviews, Podcasts, Pressekonferenzen, Reden.
|
||||||
|
Timestamped Claims: jeder Claim hat einen Zeitstempel im Original-Audio.
|
||||||
|
Provenance-Pflicht: jede audio-extrahierte Behauptung ist quellenverknüpft.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Enums
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AudioSegmentType(str, Enum):
|
||||||
|
"""Klassifikation der Audio-Quelle (Stage 11)."""
|
||||||
|
|
||||||
|
INTERVIEW = "interview"
|
||||||
|
PODCAST = "podcast"
|
||||||
|
PRESSEKONFERENZ = "pressekonferenz"
|
||||||
|
REDEN = "reden"
|
||||||
|
SONSTIGE = "sonstige"
|
||||||
|
|
||||||
|
|
||||||
|
class AudioSpeakerType(str, Enum):
|
||||||
|
"""Kategorie des Sprechers im Audio (Stage 11)."""
|
||||||
|
|
||||||
|
SPOECHTENANTWORTER = "sprechantenworter"
|
||||||
|
FRAGENSTELLER = "fragensteller"
|
||||||
|
MODERATOR = "moderator"
|
||||||
|
SONSTIGE = "sonstige"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AudioTranscriptSegmentSchema — Segment der Transkription
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AudioTranscriptSegmentSchema(BaseModel):
|
||||||
|
"""Ein Segment der Transkription (ein Zeitabschnitt mit Sprecher).
|
||||||
|
|
||||||
|
Felder:
|
||||||
|
text: Transkribierter Text des Segments
|
||||||
|
start_time: Start-Zeitstempel in Sekunden
|
||||||
|
end_time: Ende-Zeitstempel in Sekunden
|
||||||
|
speaker_id: ID des Sprechers
|
||||||
|
confidence: Confidence der STT-Erkennung
|
||||||
|
"""
|
||||||
|
|
||||||
|
text: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=1,
|
||||||
|
description="Transkribierter Text des Audio-Segments.",
|
||||||
|
)
|
||||||
|
start_time: float = Field(
|
||||||
|
...,
|
||||||
|
ge=0.0,
|
||||||
|
description="Start-Zeitstempel in Sekunden.",
|
||||||
|
)
|
||||||
|
end_time: float = Field(
|
||||||
|
...,
|
||||||
|
ge=0.0,
|
||||||
|
description="Ende-Zeitstempel in Sekunden.",
|
||||||
|
)
|
||||||
|
speaker_id: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=1,
|
||||||
|
description="ID des Sprechers (z.B. 'speaker_1', 'interviewer').",
|
||||||
|
)
|
||||||
|
confidence: float = Field(
|
||||||
|
default=0.5,
|
||||||
|
ge=0.0,
|
||||||
|
le=1.0,
|
||||||
|
description="Confidence der STT-Erkennung (0-1).",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("text")
|
||||||
|
@classmethod
|
||||||
|
def text_not_empty(cls, v: str) -> str:
|
||||||
|
if not v.strip():
|
||||||
|
raise ValueError("text darf nicht nur aus Whitespaces bestehen")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("speaker_id")
|
||||||
|
@classmethod
|
||||||
|
def speaker_id_not_empty(cls, v: str) -> str:
|
||||||
|
if not v.strip():
|
||||||
|
raise ValueError("speaker_id darf nicht leer sein")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("end_time")
|
||||||
|
@classmethod
|
||||||
|
def end_after_start(cls, v: float, info) -> float:
|
||||||
|
if hasattr(info, "data") and info.data.get("start_time") is not None:
|
||||||
|
if v < info.data["start_time"]:
|
||||||
|
raise ValueError("end_time muss nach start_time liegen")
|
||||||
|
return v
|
||||||
|
|
||||||
|
model_config = {"frozen": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AudioClaimSchema — Claim extrahiert aus Audio
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AudioClaimSchema(BaseModel):
|
||||||
|
"""Ein Claim extrahiert aus Audio mit Zeitstempel und Provenance.
|
||||||
|
|
||||||
|
Jeder Claim aus Audio hat einen Zeitstempel im Original-Audio
|
||||||
|
und muss quellenverknüpft sein (Provenance-Pflicht).
|
||||||
|
|
||||||
|
Felder:
|
||||||
|
claim_text: Die extrahierte Behauptung
|
||||||
|
timestamp: Zeitstempel des Claims im Original-Audio
|
||||||
|
speaker_id: ID des Sprechers
|
||||||
|
source_url: URL der Quelle (Provenance)
|
||||||
|
evidence_span: Zitat oder Textpassage aus dem Audio
|
||||||
|
claim_type: Art des Claims (optional)
|
||||||
|
confidence: Confidence der Claim-Extraktion
|
||||||
|
"""
|
||||||
|
|
||||||
|
claim_text: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=1,
|
||||||
|
description="Die extrahierte Behauptung aus dem Audio.",
|
||||||
|
)
|
||||||
|
timestamp_start: float = Field(
|
||||||
|
...,
|
||||||
|
ge=0.0,
|
||||||
|
description="Start-Zeitstempel des Claims im Original-Audio (Sekunden).",
|
||||||
|
)
|
||||||
|
timestamp_end: float = Field(
|
||||||
|
...,
|
||||||
|
ge=0.0,
|
||||||
|
description="Ende-Zeitstempel des Claims im Original-Audio (Sekunden).",
|
||||||
|
)
|
||||||
|
speaker_id: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=1,
|
||||||
|
description="ID des Sprechers.",
|
||||||
|
)
|
||||||
|
source_url: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=1,
|
||||||
|
description="URL der Quelle zur Provenance.",
|
||||||
|
)
|
||||||
|
evidence_span: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Zitat oder Textpassage aus dem Audio.",
|
||||||
|
)
|
||||||
|
claim_type: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Art des Claims (z.B. 'factual', 'opinion').",
|
||||||
|
)
|
||||||
|
confidence: float = Field(
|
||||||
|
default=0.5,
|
||||||
|
ge=0.0,
|
||||||
|
le=1.0,
|
||||||
|
description="Confidence der Claim-Extraktion (0-1).",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("claim_text")
|
||||||
|
@classmethod
|
||||||
|
def claim_text_not_empty(cls, v: str) -> str:
|
||||||
|
if not v.strip():
|
||||||
|
raise ValueError("claim_text darf nicht nur aus Whitespaces bestehen")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("source_url")
|
||||||
|
@classmethod
|
||||||
|
def source_url_not_empty(cls, v: str) -> str:
|
||||||
|
if not v.strip():
|
||||||
|
raise ValueError("source_url darf nicht leer sein")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("timestamp_end")
|
||||||
|
@classmethod
|
||||||
|
def end_after_start(cls, v: float, info) -> float:
|
||||||
|
if hasattr(info, "data") and info.data.get("timestamp_start") is not None:
|
||||||
|
if v < info.data["timestamp_start"]:
|
||||||
|
raise ValueError("timestamp_end muss nach timestamp_start liegen")
|
||||||
|
return v
|
||||||
|
|
||||||
|
model_config = {"frozen": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AudioReportSchema — Zusammenfassung der Audio-Analyse
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AudioReportSchema(BaseModel):
|
||||||
|
"""Zusammenfassung der Audio-Analyse (Stage 11).
|
||||||
|
|
||||||
|
Enthält alle Transkription-Segmente, extrahierten Claims,
|
||||||
|
Dauer und Sprache des Audio-Materials.
|
||||||
|
|
||||||
|
Felder:
|
||||||
|
transcript_segments: Liste aller Transkription-Segmente
|
||||||
|
claims: Liste aller extrahierten Claims
|
||||||
|
duration_seconds: Gesamtdauer des Audios in Sekunden
|
||||||
|
language: Sprache des Audio-Materials
|
||||||
|
source_url: URL der Audio-Quelle
|
||||||
|
research_run_id: UUID des Research-Runs
|
||||||
|
metadata: Zusätzliche Metadaten
|
||||||
|
"""
|
||||||
|
|
||||||
|
transcript_segments: list[AudioTranscriptSegmentSchema] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Liste aller Transkription-Segmente des Audios.",
|
||||||
|
)
|
||||||
|
claims: list[AudioClaimSchema] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Liste aller extrahierten Claims aus dem Audio.",
|
||||||
|
)
|
||||||
|
duration_seconds: float = Field(
|
||||||
|
...,
|
||||||
|
ge=0.0,
|
||||||
|
description="Gesamtdauer des Audio-Materials in Sekunden.",
|
||||||
|
)
|
||||||
|
language: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=2,
|
||||||
|
max_length=5,
|
||||||
|
description="Sprache des Audio-Materials (ISO 639-1/2 code).",
|
||||||
|
)
|
||||||
|
source_url: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
min_length=1,
|
||||||
|
description="URL der Audio-Quelle.",
|
||||||
|
)
|
||||||
|
research_run_id: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="UUID des Research-Runs zur Zuordnung.",
|
||||||
|
)
|
||||||
|
metadata: dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Zusätzliche Metadaten (z.B. model_used, processing_time).",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("language")
|
||||||
|
@classmethod
|
||||||
|
def language_not_empty(cls, v: str) -> str:
|
||||||
|
if not v.strip():
|
||||||
|
raise ValueError("language darf nicht leer sein")
|
||||||
|
return v.lower()
|
||||||
|
|
||||||
|
@field_validator("source_url")
|
||||||
|
@classmethod
|
||||||
|
def source_url_not_empty(cls, v: str | None) -> str | None:
|
||||||
|
if v is not None and not v.strip():
|
||||||
|
raise ValueError("source_url darf nicht leer sein")
|
||||||
|
return v
|
||||||
|
|
||||||
|
model_config = {"frozen": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AudioRequestSchema — API-Request
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AudioRequestSchema(BaseModel):
|
||||||
|
"""API-Request zum Verarbeiten von Audio-Material (Stage 11).
|
||||||
|
|
||||||
|
Felder:
|
||||||
|
research_run_id: UUID des Research-Runs
|
||||||
|
audio_file_url: URL der Audio-Datei
|
||||||
|
audio_bytes_b64: Base64-codiertes Audio (alternativ zu URL)
|
||||||
|
segment_type: Art des Audio-Materials
|
||||||
|
source_id: Quelle, von der das Audio stammt (Provenance)
|
||||||
|
"""
|
||||||
|
|
||||||
|
research_run_id: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=1,
|
||||||
|
description="UUID des Research-Runs.",
|
||||||
|
)
|
||||||
|
audio_file_url: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
min_length=1,
|
||||||
|
description="URL der Audio-Datei (MP3, WAV, OGG, etc.).",
|
||||||
|
)
|
||||||
|
audio_bytes_b64: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
min_length=1,
|
||||||
|
description="Base64-codiertes Audio-Bytes (alternativ zu URL).",
|
||||||
|
)
|
||||||
|
segment_type: AudioSegmentType = Field(
|
||||||
|
default=AudioSegmentType.SONSTIGE,
|
||||||
|
description="Art des Audio-Materials.",
|
||||||
|
)
|
||||||
|
source_id: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
min_length=1,
|
||||||
|
description="UUID der Quelle (source_id) zur Provenance.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("audio_file_url")
|
||||||
|
@classmethod
|
||||||
|
def audio_file_url_not_empty(cls, v: str | None) -> str | None:
|
||||||
|
if v is not None and not v.strip():
|
||||||
|
raise ValueError("audio_file_url darf nicht leer sein")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("audio_bytes_b64")
|
||||||
|
@classmethod
|
||||||
|
def audio_bytes_not_empty(cls, v: str | None) -> str | None:
|
||||||
|
if v is not None and not v.strip():
|
||||||
|
raise ValueError("audio_bytes_b64 darf nicht leer sein")
|
||||||
|
return v
|
||||||
|
|
||||||
|
model_config = {"frozen": True}
|
||||||
@@ -205,9 +205,12 @@ class VisionReportSchema(BaseModel):
|
|||||||
@field_validator("summary_text")
|
@field_validator("summary_text")
|
||||||
@classmethod
|
@classmethod
|
||||||
def summary_not_political(cls, v: str) -> str:
|
def summary_not_political(cls, v: str) -> str:
|
||||||
|
if not v.strip():
|
||||||
|
return v
|
||||||
import re
|
import re
|
||||||
|
|
||||||
forbidden = re.compile(
|
forbidden = re.compile(
|
||||||
|
r"((Regierung|Bundesregierung)\s+(muss|sollte)\s+(handeln|unterstützen)|"
|
||||||
r"(sollte\s+(Regierung|Bundesregierung)\s+(handeln|unterstützen)|"
|
r"(sollte\s+(Regierung|Bundesregierung)\s+(handeln|unterstützen)|"
|
||||||
r"muss\s+(geändert|eingesetzt|gestürzt))",
|
r"muss\s+(geändert|eingesetzt|gestürzt))",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
@@ -256,6 +259,20 @@ class VisionRequestSchema(BaseModel):
|
|||||||
min_length=1,
|
min_length=1,
|
||||||
description="Base64-codiertes Bild oder Data-URL (data:image/...).",
|
description="Base64-codiertes Bild oder Data-URL (data:image/...).",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@field_validator("research_run_id")
|
||||||
|
@classmethod
|
||||||
|
def research_run_id_not_empty(cls, v: str) -> str:
|
||||||
|
if not v.strip():
|
||||||
|
raise ValueError("research_run_id darf nicht nur aus Whitespaces bestehen")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("source_id")
|
||||||
|
@classmethod
|
||||||
|
def source_id_not_empty(cls, v: str) -> str:
|
||||||
|
if not v.strip():
|
||||||
|
raise ValueError("source_id darf nicht nur aus Whitespaces bestehen")
|
||||||
|
return v
|
||||||
capture_type: VisionCaptureType = Field(
|
capture_type: VisionCaptureType = Field(
|
||||||
default=VisionCaptureType.RAW_IMAGE,
|
default=VisionCaptureType.RAW_IMAGE,
|
||||||
description="Art der visuellen Erfassung.",
|
description="Art der visuellen Erfassung.",
|
||||||
|
|||||||
1136
src/nsct/stages/stage11_audio.py
Normal file
1136
src/nsct/stages/stage11_audio.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -538,7 +538,7 @@ class VisionEvidenceModel(Base):
|
|||||||
|
|
||||||
extracted_text = Column(Text, nullable=False)
|
extracted_text = Column(Text, nullable=False)
|
||||||
image_data_url = Column(Text, nullable=True)
|
image_data_url = Column(Text, nullable=True)
|
||||||
entities = Column(JSON, nullable=False, default=dict)
|
extracted_entities = Column(JSON, nullable=False, default=dict)
|
||||||
|
|
||||||
confidence = Column(Float, nullable=False, default=0.5)
|
confidence = Column(Float, nullable=False, default=0.5)
|
||||||
confidence_label = Column(String(16), nullable=False, default="medium")
|
confidence_label = Column(String(16), nullable=False, default="medium")
|
||||||
@@ -601,4 +601,157 @@ VisionEvidenceModel.entities = relationship(
|
|||||||
back_populates="evidence",
|
back_populates="evidence",
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
foreign_keys="VisionEntityModel.evidence_id",
|
foreign_keys="VisionEntityModel.evidence_id",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 11 — Audio Evidence Extraction (Speech-to-Text)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AudioSegmentType(str, enum.Enum):
|
||||||
|
"""Klassifikation der Audio-Quelle (Stage 11)."""
|
||||||
|
|
||||||
|
INTERVIEW = "interview"
|
||||||
|
PODCAST = "podcast"
|
||||||
|
PRESSEKONFERENZ = "pressekonferenz"
|
||||||
|
REDEN = "reden"
|
||||||
|
SONSTIGE = "sonstige"
|
||||||
|
|
||||||
|
|
||||||
|
class AudioTranscriptModel(Base):
|
||||||
|
"""Transkript eines Audio-Eintrags (Stage 11).
|
||||||
|
|
||||||
|
Felder:
|
||||||
|
uuid: Primärschlüssel (UUID)
|
||||||
|
research_run_id: Research-Run-Zuordnung
|
||||||
|
source_id: Quelle, von der das Audio stammt
|
||||||
|
segment_type: Art des Audio-Materials
|
||||||
|
transcript_text: Gesamtes Transkript als Text
|
||||||
|
audio_file_url: URL der Audio-Datei (optional)
|
||||||
|
duration_seconds: Gesamtdauer in Sekunden
|
||||||
|
language: Sprache des Audios
|
||||||
|
confidence: Confidence der STT-Erkennung
|
||||||
|
created_at / updated_at: Zeitstempel
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "audio_transcripts"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
research_run_id = Column(String(36), nullable=False)
|
||||||
|
source_id = Column(String(36), ForeignKey("sources.id"), nullable=True)
|
||||||
|
|
||||||
|
segment_type = Column(Enum(AudioSegmentType), nullable=True)
|
||||||
|
|
||||||
|
transcript_text = Column(Text, nullable=False, default="")
|
||||||
|
audio_file_url = Column(Text, nullable=True)
|
||||||
|
duration_seconds = Column(Float, nullable=False, default=0.0)
|
||||||
|
language = Column(String(16), nullable=False, default="unknown")
|
||||||
|
confidence = Column(Float, nullable=False, default=0.5)
|
||||||
|
|
||||||
|
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
segments = relationship(
|
||||||
|
"AudioTranscriptSegmentModel",
|
||||||
|
back_populates="transcript",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
claims = relationship(
|
||||||
|
"AudioClaimModel",
|
||||||
|
back_populates="transcript",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_audio_transcripts_research_run_id", "research_run_id"),
|
||||||
|
Index("ix_audio_transcripts_segment_type", "segment_type"),
|
||||||
|
Index("ix_audio_transcripts_source_id", "source_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AudioTranscriptSegmentModel(Base):
|
||||||
|
"""Segment des Audio-Transkripts (Stage 11).
|
||||||
|
|
||||||
|
Felder:
|
||||||
|
uuid: Primärschlüssel (UUID)
|
||||||
|
transcript_id: FK zum AudioTranscript
|
||||||
|
start_time: Start-Zeitstempel in Sekunden
|
||||||
|
end_time: Ende-Zeitstempel in Sekunden
|
||||||
|
text: Transkribierter Text
|
||||||
|
speaker_id: ID des Sprechers
|
||||||
|
speaker_type: Kategorie des Sprechers
|
||||||
|
confidence: Confidence der STT-Erkennung
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "audio_transcript_segments"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
transcript_id = Column(
|
||||||
|
String(36),
|
||||||
|
ForeignKey("audio_transcripts.id"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
start_time = Column(Float, nullable=False, default=0.0)
|
||||||
|
end_time = Column(Float, nullable=False, default=0.0)
|
||||||
|
text = Column(Text, nullable=False, default="")
|
||||||
|
speaker_id = Column(String(64), nullable=False, default="")
|
||||||
|
speaker_type = Column(String(64), nullable=True)
|
||||||
|
confidence = Column(Float, nullable=False, default=0.5)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
transcript = relationship("AudioTranscriptModel", back_populates="segments")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_audio_transcript_segments_transcript_id", "transcript_id"),
|
||||||
|
Index("ix_audio_transcript_segments_speaker_id", "speaker_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AudioClaimModel(Base):
|
||||||
|
"""Claim extrahiert aus Audio mit Zeitstempel (Stage 11).
|
||||||
|
|
||||||
|
Jeder Claim aus Audio hat einen Zeitstempel im Original-Audio
|
||||||
|
und muss quellenverknüpft sein (Provenance-Pflicht).
|
||||||
|
|
||||||
|
Felder:
|
||||||
|
uuid: Primärschlüssel (UUID)
|
||||||
|
transcript_id: FK zum AudioTranscript
|
||||||
|
claim_text: Die extrahierte Behauptung
|
||||||
|
timestamp_start: Start-Zeitstempel im Original-Audio
|
||||||
|
timestamp_end: Ende-Zeitstempel im Original-Audio
|
||||||
|
speaker_id: ID des Sprechers
|
||||||
|
source_url: URL der Quelle (Provenance)
|
||||||
|
evidence_span: Zitat oder Textpassage
|
||||||
|
claim_type: Art des Claims
|
||||||
|
confidence: Confidence der Claim-Extraktion
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "audio_claims"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
transcript_id = Column(
|
||||||
|
String(36),
|
||||||
|
ForeignKey("audio_transcripts.id"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
claim_text = Column(Text, nullable=False)
|
||||||
|
timestamp_start = Column(Float, nullable=False, default=0.0)
|
||||||
|
timestamp_end = Column(Float, nullable=False, default=0.0)
|
||||||
|
speaker_id = Column(String(64), nullable=False, default="")
|
||||||
|
source_url = Column(Text, nullable=True)
|
||||||
|
evidence_span = Column(Text, nullable=True)
|
||||||
|
claim_type = Column(String(64), nullable=True)
|
||||||
|
confidence = Column(Float, nullable=False, default=0.5)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
transcript = relationship("AudioTranscriptModel", back_populates="claims")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_audio_claims_transcript_id", "transcript_id"),
|
||||||
|
Index("ix_audio_claims_claim_type", "claim_type"),
|
||||||
|
Index("ix_audio_claims_speaker_id", "speaker_id"),
|
||||||
|
)
|
||||||
637
tests/models/test_audio.py
Normal file
637
tests/models/test_audio.py
Normal file
@@ -0,0 +1,637 @@
|
|||||||
|
"""Tests für Pydantic v2 Schemas und SQLAlchemy Models der Audio Evidence Extraction (Stage 11)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from nsct.models.audio import (
|
||||||
|
AudioClaimSchema,
|
||||||
|
AudioRequestSchema,
|
||||||
|
AudioReportSchema,
|
||||||
|
AudioSegmentType,
|
||||||
|
AudioSpeakerType,
|
||||||
|
AudioTranscriptSegmentSchema,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Enums
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudioSegmentTypeEnum:
|
||||||
|
"""Prüft die Enums AudioSegmentType und AudioSpeakerType."""
|
||||||
|
|
||||||
|
def test_audio_segment_type_values(self):
|
||||||
|
assert AudioSegmentType.INTERVIEW.value == "interview"
|
||||||
|
assert AudioSegmentType.PODCAST.value == "podcast"
|
||||||
|
assert AudioSegmentType.PRESSEKONFERENZ.value == "pressekonferenz"
|
||||||
|
assert AudioSegmentType.REDEN.value == "reden"
|
||||||
|
assert AudioSegmentType.SONSTIGE.value == "sonstige"
|
||||||
|
|
||||||
|
def test_audio_speaker_type_values(self):
|
||||||
|
assert AudioSpeakerType.SPOECHTENANTWORTER.value == "sprechantenworter"
|
||||||
|
assert AudioSpeakerType.FRAGENSTELLER.value == "fragensteller"
|
||||||
|
assert AudioSpeakerType.MODERATOR.value == "moderator"
|
||||||
|
assert AudioSpeakerType.SONSTIGE.value == "sonstige"
|
||||||
|
|
||||||
|
def test_invalid_segment_type(self):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AudioTranscriptSegmentSchema(
|
||||||
|
text="test",
|
||||||
|
start_time=0.0,
|
||||||
|
end_time=1.0,
|
||||||
|
speaker_id="speaker_1",
|
||||||
|
segment_type="invalid", # type: ignore
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invalid_speaker_type(self):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AudioTranscriptSegmentSchema(
|
||||||
|
text="test",
|
||||||
|
start_time=0.0,
|
||||||
|
end_time=1.0,
|
||||||
|
speaker_id="speaker_1",
|
||||||
|
speaker_type="invalid", # type: ignore
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_all_segment_types(self):
|
||||||
|
for st in AudioSegmentType:
|
||||||
|
schema = AudioTranscriptSegmentSchema(
|
||||||
|
text="test content",
|
||||||
|
start_time=0.0,
|
||||||
|
end_time=1.0,
|
||||||
|
speaker_id="speaker_1",
|
||||||
|
)
|
||||||
|
assert isinstance(schema, AudioTranscriptSegmentSchema)
|
||||||
|
|
||||||
|
def test_all_speaker_types(self):
|
||||||
|
for st in AudioSpeakerType:
|
||||||
|
schema = AudioTranscriptSegmentSchema(
|
||||||
|
text="test content",
|
||||||
|
start_time=0.0,
|
||||||
|
end_time=1.0,
|
||||||
|
speaker_id="speaker_1",
|
||||||
|
)
|
||||||
|
assert isinstance(schema, AudioTranscriptSegmentSchema)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AudioTranscriptSegmentSchema
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudioTranscriptSegmentSchema:
|
||||||
|
"""Tests für AudioTranscriptSegmentSchema — Pflichtfelder, Defaults, Frozen."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def base_kwargs(self):
|
||||||
|
return {
|
||||||
|
"text": "Dies ist ein Testtranskript.",
|
||||||
|
"start_time": 0.0,
|
||||||
|
"end_time": 5.0,
|
||||||
|
"speaker_id": "speaker_1",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_create_valid_segment(self, base_kwargs):
|
||||||
|
segment = AudioTranscriptSegmentSchema(**base_kwargs)
|
||||||
|
assert segment.text == "Dies ist ein Testtranskript."
|
||||||
|
assert segment.start_time == 0.0
|
||||||
|
assert segment.end_time == 5.0
|
||||||
|
assert segment.speaker_id == "speaker_1"
|
||||||
|
assert segment.confidence == 0.5
|
||||||
|
|
||||||
|
def test_defaults(self, base_kwargs):
|
||||||
|
segment = AudioTranscriptSegmentSchema(**base_kwargs)
|
||||||
|
assert segment.confidence == 0.5
|
||||||
|
|
||||||
|
def test_frozen(self, base_kwargs):
|
||||||
|
segment = AudioTranscriptSegmentSchema(**base_kwargs)
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
segment.text = "modified"
|
||||||
|
|
||||||
|
def test_missing_text(self, base_kwargs):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AudioTranscriptSegmentSchema(
|
||||||
|
start_time=base_kwargs["start_time"],
|
||||||
|
end_time=base_kwargs["end_time"],
|
||||||
|
speaker_id=base_kwargs["speaker_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_text(self, base_kwargs):
|
||||||
|
with pytest.raises(ValidationError, match="text darf nicht nur aus Whitespaces bestehen"):
|
||||||
|
AudioTranscriptSegmentSchema(
|
||||||
|
text=" ",
|
||||||
|
start_time=base_kwargs["start_time"],
|
||||||
|
end_time=base_kwargs["end_time"],
|
||||||
|
speaker_id=base_kwargs["speaker_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_speaker_id(self, base_kwargs):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AudioTranscriptSegmentSchema(
|
||||||
|
text=base_kwargs["text"],
|
||||||
|
start_time=base_kwargs["start_time"],
|
||||||
|
end_time=base_kwargs["end_time"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_speaker_id(self, base_kwargs):
|
||||||
|
with pytest.raises(ValidationError, match="speaker_id darf nicht leer sein"):
|
||||||
|
AudioTranscriptSegmentSchema(
|
||||||
|
text=base_kwargs["text"],
|
||||||
|
start_time=base_kwargs["start_time"],
|
||||||
|
end_time=base_kwargs["end_time"],
|
||||||
|
speaker_id=" ",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_start_time(self, base_kwargs):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AudioTranscriptSegmentSchema(
|
||||||
|
text=base_kwargs["text"],
|
||||||
|
end_time=base_kwargs["end_time"],
|
||||||
|
speaker_id=base_kwargs["speaker_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_end_time(self, base_kwargs):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AudioTranscriptSegmentSchema(
|
||||||
|
text=base_kwargs["text"],
|
||||||
|
start_time=base_kwargs["start_time"],
|
||||||
|
speaker_id=base_kwargs["speaker_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_negative_start_time(self, base_kwargs):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AudioTranscriptSegmentSchema(
|
||||||
|
text=base_kwargs["text"],
|
||||||
|
start_time=-1.0,
|
||||||
|
end_time=base_kwargs["end_time"],
|
||||||
|
speaker_id=base_kwargs["speaker_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_end_before_start(self):
|
||||||
|
with pytest.raises(ValidationError, match="end_time muss nach start_time liegen"):
|
||||||
|
AudioTranscriptSegmentSchema(
|
||||||
|
text="test",
|
||||||
|
start_time=10.0,
|
||||||
|
end_time=5.0,
|
||||||
|
speaker_id="speaker_1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_confidence_bounds(self, base_kwargs):
|
||||||
|
schema_low = AudioTranscriptSegmentSchema(**base_kwargs, confidence=0.0)
|
||||||
|
assert schema_low.confidence == 0.0
|
||||||
|
|
||||||
|
schema_high = AudioTranscriptSegmentSchema(**base_kwargs, confidence=1.0)
|
||||||
|
assert schema_high.confidence == 1.0
|
||||||
|
|
||||||
|
def test_confidence_out_of_bounds_low(self, base_kwargs):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AudioTranscriptSegmentSchema(**base_kwargs, confidence=-0.1)
|
||||||
|
|
||||||
|
def test_confidence_out_of_bounds_high(self, base_kwargs):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AudioTranscriptSegmentSchema(**base_kwargs, confidence=1.1)
|
||||||
|
|
||||||
|
def test_high_confidence(self, base_kwargs):
|
||||||
|
segment = AudioTranscriptSegmentSchema(**base_kwargs, confidence=0.95)
|
||||||
|
assert segment.confidence == 0.95
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AudioClaimSchema
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudioClaimSchema:
|
||||||
|
"""Tests für AudioClaimSchema — Claim mit Provenance und Timestamp."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def base_kwargs(self):
|
||||||
|
return {
|
||||||
|
"claim_text": "Die Regierung hat die Ausgaben erhöht.",
|
||||||
|
"timestamp_start": 120.0,
|
||||||
|
"timestamp_end": 125.0,
|
||||||
|
"speaker_id": "minister_1",
|
||||||
|
"source_url": "https://example.com/interview.mp3",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_create_valid_claim(self, base_kwargs):
|
||||||
|
claim = AudioClaimSchema(**base_kwargs)
|
||||||
|
assert claim.claim_text == "Die Regierung hat die Ausgaben erhöht."
|
||||||
|
assert claim.timestamp_start == 120.0
|
||||||
|
assert claim.timestamp_end == 125.0
|
||||||
|
assert claim.speaker_id == "minister_1"
|
||||||
|
assert claim.source_url == "https://example.com/interview.mp3"
|
||||||
|
assert claim.confidence == 0.5
|
||||||
|
assert claim.evidence_span is None
|
||||||
|
assert claim.claim_type is None
|
||||||
|
|
||||||
|
def test_defaults(self, base_kwargs):
|
||||||
|
claim = AudioClaimSchema(**base_kwargs)
|
||||||
|
assert claim.confidence == 0.5
|
||||||
|
assert claim.evidence_span is None
|
||||||
|
assert claim.claim_type is None
|
||||||
|
|
||||||
|
def test_frozen(self, base_kwargs):
|
||||||
|
claim = AudioClaimSchema(**base_kwargs)
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
claim.claim_text = "modified"
|
||||||
|
|
||||||
|
def test_with_evidence_span(self, base_kwargs):
|
||||||
|
claim = AudioClaimSchema(
|
||||||
|
**base_kwargs,
|
||||||
|
evidence_span="Laut dem Haushaltsgesetz 2024 wurden die Ausgaben um 15% erhöht.",
|
||||||
|
)
|
||||||
|
assert claim.evidence_span == "Laut dem Haushaltsgesetz 2024 wurden die Ausgaben um 15% erhöht."
|
||||||
|
|
||||||
|
def test_with_claim_type(self, base_kwargs):
|
||||||
|
claim = AudioClaimSchema(**base_kwargs, claim_type="factual")
|
||||||
|
assert claim.claim_type == "factual"
|
||||||
|
|
||||||
|
def test_empty_claim_text(self, base_kwargs):
|
||||||
|
with pytest.raises(ValidationError, match="claim_text darf nicht nur aus Whitespaces bestehen"):
|
||||||
|
AudioClaimSchema(
|
||||||
|
claim_text=" ",
|
||||||
|
timestamp_start=base_kwargs["timestamp_start"],
|
||||||
|
timestamp_end=base_kwargs["timestamp_end"],
|
||||||
|
speaker_id=base_kwargs["speaker_id"],
|
||||||
|
source_url=base_kwargs["source_url"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_claim_text(self, base_kwargs):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AudioClaimSchema(
|
||||||
|
timestamp_start=base_kwargs["timestamp_start"],
|
||||||
|
timestamp_end=base_kwargs["timestamp_end"],
|
||||||
|
speaker_id=base_kwargs["speaker_id"],
|
||||||
|
source_url=base_kwargs["source_url"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_speaker_id(self, base_kwargs):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AudioClaimSchema(
|
||||||
|
claim_text=base_kwargs["claim_text"],
|
||||||
|
timestamp_start=base_kwargs["timestamp_start"],
|
||||||
|
timestamp_end=base_kwargs["timestamp_end"],
|
||||||
|
source_url=base_kwargs["source_url"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_speaker_id(self, base_kwargs):
|
||||||
|
with pytest.raises(ValidationError, match="speaker_id darf nicht leer sein"):
|
||||||
|
AudioClaimSchema(
|
||||||
|
claim_text=base_kwargs["claim_text"],
|
||||||
|
timestamp_start=base_kwargs["timestamp_start"],
|
||||||
|
timestamp_end=base_kwargs["timestamp_end"],
|
||||||
|
speaker_id=" ",
|
||||||
|
source_url=base_kwargs["source_url"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_source_url(self, base_kwargs):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AudioClaimSchema(
|
||||||
|
claim_text=base_kwargs["claim_text"],
|
||||||
|
timestamp_start=base_kwargs["timestamp_start"],
|
||||||
|
timestamp_end=base_kwargs["timestamp_end"],
|
||||||
|
speaker_id=base_kwargs["speaker_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_source_url(self, base_kwargs):
|
||||||
|
with pytest.raises(ValidationError, match="source_url darf nicht leer sein"):
|
||||||
|
AudioClaimSchema(
|
||||||
|
claim_text=base_kwargs["claim_text"],
|
||||||
|
timestamp_start=base_kwargs["timestamp_start"],
|
||||||
|
timestamp_end=base_kwargs["timestamp_end"],
|
||||||
|
speaker_id=base_kwargs["speaker_id"],
|
||||||
|
source_url=" ",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_timestamp_end_before_start(self):
|
||||||
|
with pytest.raises(ValidationError, match="timestamp_end muss nach timestamp_start liegen"):
|
||||||
|
AudioClaimSchema(
|
||||||
|
claim_text="test",
|
||||||
|
timestamp_start=10.0,
|
||||||
|
timestamp_end=5.0,
|
||||||
|
speaker_id="speaker_1",
|
||||||
|
source_url="https://example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_timestamp_bounds(self):
|
||||||
|
claim = AudioClaimSchema(
|
||||||
|
claim_text="test",
|
||||||
|
timestamp_start=0.0,
|
||||||
|
timestamp_end=0.0,
|
||||||
|
speaker_id="speaker_1",
|
||||||
|
source_url="https://example.com",
|
||||||
|
)
|
||||||
|
assert claim.timestamp_start == 0.0
|
||||||
|
assert claim.timestamp_end == 0.0
|
||||||
|
|
||||||
|
def test_negative_timestamp_start(self):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AudioClaimSchema(
|
||||||
|
claim_text="test",
|
||||||
|
timestamp_start=-1.0,
|
||||||
|
timestamp_end=10.0,
|
||||||
|
speaker_id="speaker_1",
|
||||||
|
source_url="https://example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_confidence_bounds(self, base_kwargs):
|
||||||
|
schema_low = AudioClaimSchema(**base_kwargs, confidence=0.0)
|
||||||
|
assert schema_low.confidence == 0.0
|
||||||
|
|
||||||
|
schema_high = AudioClaimSchema(**base_kwargs, confidence=1.0)
|
||||||
|
assert schema_high.confidence == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AudioReportSchema
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudioReportSchema:
|
||||||
|
"""Tests für AudioReportSchema — Zusammenfassung der Audio-Analyse."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def base_kwargs(self):
|
||||||
|
return {
|
||||||
|
"duration_seconds": 3600.0,
|
||||||
|
"language": "de",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_create_valid_report(self, base_kwargs):
|
||||||
|
report = AudioReportSchema(**base_kwargs)
|
||||||
|
assert report.duration_seconds == 3600.0
|
||||||
|
assert report.language == "de"
|
||||||
|
assert report.transcript_segments == []
|
||||||
|
assert report.claims == []
|
||||||
|
assert report.source_url is None
|
||||||
|
assert report.research_run_id is None
|
||||||
|
assert report.metadata == {}
|
||||||
|
|
||||||
|
def test_defaults(self, base_kwargs):
|
||||||
|
report = AudioReportSchema(**base_kwargs)
|
||||||
|
assert report.transcript_segments == []
|
||||||
|
assert report.claims == []
|
||||||
|
assert report.source_url is None
|
||||||
|
assert report.research_run_id is None
|
||||||
|
assert report.metadata == {}
|
||||||
|
|
||||||
|
def test_frozen(self, base_kwargs):
|
||||||
|
report = AudioReportSchema(**base_kwargs)
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
report.duration_seconds = 7200.0
|
||||||
|
|
||||||
|
def test_with_transcript_segments(self, base_kwargs):
|
||||||
|
segment = AudioTranscriptSegmentSchema(
|
||||||
|
text="Guten Tag, ich möchte Sie etwas fragen.",
|
||||||
|
start_time=0.0,
|
||||||
|
end_time=3.0,
|
||||||
|
speaker_id="interviewer",
|
||||||
|
)
|
||||||
|
report = AudioReportSchema(
|
||||||
|
**base_kwargs,
|
||||||
|
transcript_segments=[segment],
|
||||||
|
)
|
||||||
|
assert len(report.transcript_segments) == 1
|
||||||
|
assert report.transcript_segments[0].text == "Guten Tag, ich möchte Sie etwas fragen."
|
||||||
|
|
||||||
|
def test_with_claims(self, base_kwargs):
|
||||||
|
claim = AudioClaimSchema(
|
||||||
|
claim_text="Die Regierung hat die Ausgaben erhöht.",
|
||||||
|
timestamp_start=120.0,
|
||||||
|
timestamp_end=125.0,
|
||||||
|
speaker_id="minister_1",
|
||||||
|
source_url="https://example.com/interview.mp3",
|
||||||
|
)
|
||||||
|
report = AudioReportSchema(
|
||||||
|
**base_kwargs,
|
||||||
|
claims=[claim],
|
||||||
|
)
|
||||||
|
assert len(report.claims) == 1
|
||||||
|
assert report.claims[0].claim_text == "Die Regierung hat die Ausgaben erhöht."
|
||||||
|
|
||||||
|
def test_with_source_url(self, base_kwargs):
|
||||||
|
report = AudioReportSchema(
|
||||||
|
**base_kwargs,
|
||||||
|
source_url="https://example.com/podcast.mp3",
|
||||||
|
)
|
||||||
|
assert report.source_url == "https://example.com/podcast.mp3"
|
||||||
|
|
||||||
|
def test_with_research_run_id(self, base_kwargs):
|
||||||
|
report = AudioReportSchema(
|
||||||
|
**base_kwargs,
|
||||||
|
research_run_id="run-uuid-001",
|
||||||
|
)
|
||||||
|
assert report.research_run_id == "run-uuid-001"
|
||||||
|
|
||||||
|
def test_empty_source_url(self, base_kwargs):
|
||||||
|
with pytest.raises(ValidationError, match="source_url darf nicht leer sein"):
|
||||||
|
AudioReportSchema(
|
||||||
|
**base_kwargs,
|
||||||
|
source_url=" ",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_language(self, base_kwargs):
|
||||||
|
with pytest.raises(ValidationError, match="language darf nicht leer sein"):
|
||||||
|
AudioReportSchema(
|
||||||
|
**base_kwargs,
|
||||||
|
language=" ",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_language_normalized_to_lower(self, base_kwargs):
|
||||||
|
report = AudioReportSchema(
|
||||||
|
**base_kwargs,
|
||||||
|
language="DE",
|
||||||
|
)
|
||||||
|
assert report.language == "de"
|
||||||
|
|
||||||
|
def test_negative_duration(self):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AudioReportSchema(
|
||||||
|
duration_seconds=-1.0,
|
||||||
|
language="de",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_zero_duration(self):
|
||||||
|
report = AudioReportSchema(
|
||||||
|
duration_seconds=0.0,
|
||||||
|
language="de",
|
||||||
|
)
|
||||||
|
assert report.duration_seconds == 0.0
|
||||||
|
|
||||||
|
def test_metadata_dict(self, base_kwargs):
|
||||||
|
report = AudioReportSchema(
|
||||||
|
**base_kwargs,
|
||||||
|
metadata={"model": "whisper-3", "processing_time": 12.5},
|
||||||
|
)
|
||||||
|
assert report.metadata["model"] == "whisper-3"
|
||||||
|
assert report.metadata["processing_time"] == 12.5
|
||||||
|
|
||||||
|
def test_full_report(self, base_kwargs):
|
||||||
|
segment = AudioTranscriptSegmentSchema(
|
||||||
|
text="Interview: Was denken Sie über die Wirtschaftslage?",
|
||||||
|
start_time=0.0,
|
||||||
|
end_time=5.0,
|
||||||
|
speaker_id="interviewer",
|
||||||
|
)
|
||||||
|
claim = AudioClaimSchema(
|
||||||
|
claim_text="Die Wirtschaftslage ist stabil.",
|
||||||
|
timestamp_start=5.0,
|
||||||
|
timestamp_end=10.0,
|
||||||
|
speaker_id="interviewee",
|
||||||
|
source_url="https://example.com/interview.mp3",
|
||||||
|
)
|
||||||
|
report = AudioReportSchema(
|
||||||
|
**base_kwargs,
|
||||||
|
transcript_segments=[segment],
|
||||||
|
claims=[claim],
|
||||||
|
source_url="https://example.com/interview.mp3",
|
||||||
|
research_run_id="run-uuid-001",
|
||||||
|
metadata={"model": "whisper-3"},
|
||||||
|
)
|
||||||
|
assert len(report.transcript_segments) == 1
|
||||||
|
assert len(report.claims) == 1
|
||||||
|
assert report.source_url == "https://example.com/interview.mp3"
|
||||||
|
assert report.research_run_id == "run-uuid-001"
|
||||||
|
|
||||||
|
def test_language_short_code(self, base_kwargs):
|
||||||
|
"""Kurze ISO 639-1 Codes sind erlaubt (min_length=2)."""
|
||||||
|
report = AudioReportSchema(**base_kwargs, language="en")
|
||||||
|
assert report.language == "en"
|
||||||
|
|
||||||
|
def test_language_long_code(self, base_kwargs):
|
||||||
|
"""Längere Codes bis max_length=5 sind erlaubt."""
|
||||||
|
report = AudioReportSchema(**base_kwargs, language="deu")
|
||||||
|
assert report.language == "deu"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AudioRequestSchema
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudioRequestSchema:
|
||||||
|
"""Tests für AudioRequestSchema — API-Request."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def base_kwargs(self):
|
||||||
|
return {
|
||||||
|
"research_run_id": "run-uuid-001",
|
||||||
|
"audio_file_url": "https://example.com/interview.mp3",
|
||||||
|
"segment_type": AudioSegmentType.INTERVIEW,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_create_valid_request(self, base_kwargs):
|
||||||
|
request = AudioRequestSchema(**base_kwargs)
|
||||||
|
assert request.research_run_id == "run-uuid-001"
|
||||||
|
assert request.audio_file_url == "https://example.com/interview.mp3"
|
||||||
|
assert request.audio_bytes_b64 is None
|
||||||
|
assert request.segment_type == AudioSegmentType.INTERVIEW
|
||||||
|
assert request.source_id is None
|
||||||
|
|
||||||
|
def test_defaults(self, base_kwargs):
|
||||||
|
request = AudioRequestSchema(**base_kwargs)
|
||||||
|
assert request.audio_bytes_b64 is None
|
||||||
|
assert request.segment_type == AudioSegmentType.INTERVIEW
|
||||||
|
assert request.source_id is None
|
||||||
|
|
||||||
|
def test_frozen(self, base_kwargs):
|
||||||
|
request = AudioRequestSchema(**base_kwargs)
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
request.research_run_id = "new-id"
|
||||||
|
|
||||||
|
def test_with_audio_bytes_b64(self, base_kwargs):
|
||||||
|
request = AudioRequestSchema(
|
||||||
|
**base_kwargs,
|
||||||
|
audio_file_url=None,
|
||||||
|
audio_bytes_b64="base64encodedaudiodata==",
|
||||||
|
)
|
||||||
|
assert request.audio_file_url is None
|
||||||
|
assert request.audio_bytes_b64 == "base64encodedaudiodata=="
|
||||||
|
|
||||||
|
def test_with_source_id(self, base_kwargs):
|
||||||
|
request = AudioRequestSchema(
|
||||||
|
**base_kwargs,
|
||||||
|
source_id="source-uuid-001",
|
||||||
|
)
|
||||||
|
assert request.source_id == "source-uuid-001"
|
||||||
|
|
||||||
|
def test_missing_research_run_id(self):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AudioRequestSchema(
|
||||||
|
audio_file_url="https://example.com/interview.mp3",
|
||||||
|
segment_type=AudioSegmentType.INTERVIEW,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_research_run_id(self):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AudioRequestSchema(
|
||||||
|
research_run_id=" ",
|
||||||
|
audio_file_url="https://example.com/interview.mp3",
|
||||||
|
segment_type=AudioSegmentType.INTERVIEW,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_audio_file_url(self, base_kwargs):
|
||||||
|
with pytest.raises(ValidationError, match="audio_file_url darf nicht leer sein"):
|
||||||
|
AudioRequestSchema(
|
||||||
|
**base_kwargs,
|
||||||
|
audio_file_url=" ",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_audio_bytes_b64(self, base_kwargs):
|
||||||
|
with pytest.raises(ValidationError, match="audio_bytes_b64 darf nicht leer sein"):
|
||||||
|
AudioRequestSchema(
|
||||||
|
**base_kwargs,
|
||||||
|
audio_file_url=None,
|
||||||
|
audio_bytes_b64=" ",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_podcast_segment_type(self, base_kwargs):
|
||||||
|
request = AudioRequestSchema(
|
||||||
|
**base_kwargs,
|
||||||
|
segment_type=AudioSegmentType.PODCAST,
|
||||||
|
)
|
||||||
|
assert request.segment_type == AudioSegmentType.PODCAST
|
||||||
|
|
||||||
|
def test_press_conference_segment_type(self, base_kwargs):
|
||||||
|
request = AudioRequestSchema(
|
||||||
|
**base_kwargs,
|
||||||
|
segment_type=AudioSegmentType.PRESSEKONFERENZ,
|
||||||
|
)
|
||||||
|
assert request.segment_type == AudioSegmentType.PRESSEKONFERENZ
|
||||||
|
|
||||||
|
def test_speeches_segment_type(self, base_kwargs):
|
||||||
|
request = AudioRequestSchema(
|
||||||
|
**base_kwargs,
|
||||||
|
segment_type=AudioSegmentType.REDEN,
|
||||||
|
)
|
||||||
|
assert request.segment_type == AudioSegmentType.REDEN
|
||||||
|
|
||||||
|
def test_other_segment_type(self, base_kwargs):
|
||||||
|
request = AudioRequestSchema(
|
||||||
|
**base_kwargs,
|
||||||
|
segment_type=AudioSegmentType.SONSTIGE,
|
||||||
|
)
|
||||||
|
assert request.segment_type == AudioSegmentType.SONSTIGE
|
||||||
|
|
||||||
|
def test_segment_type_override(self):
|
||||||
|
for st in AudioSegmentType:
|
||||||
|
request = AudioRequestSchema(
|
||||||
|
research_run_id="run-uuid-001",
|
||||||
|
audio_file_url="https://example.com/audio.mp3",
|
||||||
|
segment_type=st,
|
||||||
|
)
|
||||||
|
assert request.segment_type == st
|
||||||
|
|
||||||
|
def test_no_audio_url_or_bytes(self, base_kwargs):
|
||||||
|
"""Erlaubt: kein audio_file_url UND kein audio_bytes_b64 (beide optional)."""
|
||||||
|
request = AudioRequestSchema(
|
||||||
|
**base_kwargs,
|
||||||
|
audio_file_url=None,
|
||||||
|
audio_bytes_b64=None,
|
||||||
|
)
|
||||||
|
assert request.audio_file_url is None
|
||||||
|
assert request.audio_bytes_b64 is None
|
||||||
@@ -84,7 +84,7 @@ class TestVisionCaptureSchema:
|
|||||||
"""Tests für VisionCaptureSchema — Pflichtfelder, Defaults, Frozen."""
|
"""Tests für VisionCaptureSchema — Pflichtfelder, Defaults, Frozen."""
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def valid_kwargs(self):
|
def base_kwargs(self):
|
||||||
return {
|
return {
|
||||||
"capture_type": VisionCaptureType.DIAGRAM,
|
"capture_type": VisionCaptureType.DIAGRAM,
|
||||||
"image_data_url": "data:image/png;base64,abc123",
|
"image_data_url": "data:image/png;base64,abc123",
|
||||||
@@ -93,8 +93,8 @@ class TestVisionCaptureSchema:
|
|||||||
"source_url": "https://example.com/chart.png",
|
"source_url": "https://example.com/chart.png",
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_create_valid_schema(self, valid_kwargs):
|
def test_create_valid_schema(self, base_kwargs):
|
||||||
schema = VisionCaptureSchema(**valid_kwargs)
|
schema = VisionCaptureSchema(**base_kwargs)
|
||||||
assert schema.capture_type == VisionCaptureType.DIAGRAM
|
assert schema.capture_type == VisionCaptureType.DIAGRAM
|
||||||
assert schema.extracted_text == "This is a chart showing revenue growth."
|
assert schema.extracted_text == "This is a chart showing revenue growth."
|
||||||
assert schema.source_id == "source-uuid-001"
|
assert schema.source_id == "source-uuid-001"
|
||||||
@@ -104,54 +104,80 @@ class TestVisionCaptureSchema:
|
|||||||
assert schema.evidence_level == EvidenceLevel.MEDIUM
|
assert schema.evidence_level == EvidenceLevel.MEDIUM
|
||||||
assert schema.metadata == {}
|
assert schema.metadata == {}
|
||||||
|
|
||||||
def test_defaults(self, valid_kwargs):
|
def test_defaults(self, base_kwargs):
|
||||||
schema = VisionCaptureSchema(**valid_kwargs)
|
schema = VisionCaptureSchema(**base_kwargs)
|
||||||
assert schema.entities == []
|
assert schema.entities == []
|
||||||
assert schema.confidence == 0.5
|
assert schema.confidence == 0.5
|
||||||
assert schema.confidence_label == VisionConfidence.MEDIUM
|
assert schema.confidence_label == VisionConfidence.MEDIUM
|
||||||
assert schema.evidence_level == EvidenceLevel.MEDIUM
|
assert schema.evidence_level == EvidenceLevel.MEDIUM
|
||||||
assert schema.metadata == {}
|
assert schema.metadata == {}
|
||||||
|
|
||||||
def test_frozen(self, valid_kwargs):
|
def test_frozen(self, base_kwargs):
|
||||||
schema = VisionCaptureSchema(**valid_kwargs)
|
schema = VisionCaptureSchema(**base_kwargs)
|
||||||
with pytest.raises(Exception):
|
with pytest.raises(Exception):
|
||||||
schema.capture_type = VisionCaptureType.CHART
|
schema.capture_type = VisionCaptureType.CHART
|
||||||
|
|
||||||
def test_missing_required_field(self, valid_kwargs):
|
def test_missing_extracted_text(self, base_kwargs):
|
||||||
kwargs = {**valid_kwargs}
|
|
||||||
del kwargs["extracted_text"]
|
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
VisionCaptureSchema(**kwargs)
|
VisionCaptureSchema(
|
||||||
|
capture_type=base_kwargs["capture_type"],
|
||||||
|
image_data_url=base_kwargs["image_data_url"],
|
||||||
|
source_id=base_kwargs["source_id"],
|
||||||
|
source_url=base_kwargs["source_url"],
|
||||||
|
)
|
||||||
|
|
||||||
def test_empty_extracted_text(self, valid_kwargs):
|
def test_empty_extracted_text(self, base_kwargs):
|
||||||
kwargs = {**valid_kwargs, "extracted_text": " "}
|
|
||||||
with pytest.raises(ValidationError, match="extracted_text darf nicht nur aus Whitespaces bestehen"):
|
with pytest.raises(ValidationError, match="extracted_text darf nicht nur aus Whitespaces bestehen"):
|
||||||
VisionCaptureSchema(**kwargs)
|
VisionCaptureSchema(
|
||||||
|
capture_type=base_kwargs["capture_type"],
|
||||||
|
image_data_url=base_kwargs["image_data_url"],
|
||||||
|
extracted_text=" ",
|
||||||
|
source_id=base_kwargs["source_id"],
|
||||||
|
source_url=base_kwargs["source_url"],
|
||||||
|
)
|
||||||
|
|
||||||
def test_empty_source_url(self, valid_kwargs):
|
def test_empty_source_url(self, base_kwargs):
|
||||||
kwargs = {**valid_kwargs, "source_url": " "}
|
|
||||||
with pytest.raises(ValidationError, match="source_url darf nicht leer sein"):
|
with pytest.raises(ValidationError, match="source_url darf nicht leer sein"):
|
||||||
VisionCaptureSchema(**kwargs)
|
VisionCaptureSchema(
|
||||||
|
capture_type=base_kwargs["capture_type"],
|
||||||
|
image_data_url=base_kwargs["image_data_url"],
|
||||||
|
extracted_text=base_kwargs["extracted_text"],
|
||||||
|
source_id=base_kwargs["source_id"],
|
||||||
|
source_url=" ",
|
||||||
|
)
|
||||||
|
|
||||||
def test_empty_image_data_url(self, valid_kwargs):
|
def test_empty_image_data_url(self, base_kwargs):
|
||||||
kwargs = {**valid_kwargs, "image_data_url": " "}
|
|
||||||
with pytest.raises(ValidationError, match="image_data_url darf nicht leer sein"):
|
with pytest.raises(ValidationError, match="image_data_url darf nicht leer sein"):
|
||||||
VisionCaptureSchema(**kwargs)
|
VisionCaptureSchema(
|
||||||
|
capture_type=base_kwargs["capture_type"],
|
||||||
|
image_data_url=" ",
|
||||||
|
extracted_text=base_kwargs["extracted_text"],
|
||||||
|
source_id=base_kwargs["source_id"],
|
||||||
|
source_url=base_kwargs["source_url"],
|
||||||
|
)
|
||||||
|
|
||||||
def test_confidence_bounds(self, valid_kwargs):
|
def test_confidence_bounds(self, base_kwargs):
|
||||||
schema_low = VisionCaptureSchema(**valid_kwargs, confidence=0.0)
|
schema_low = VisionCaptureSchema(
|
||||||
|
**base_kwargs, confidence=0.0
|
||||||
|
)
|
||||||
assert schema_low.confidence == 0.0
|
assert schema_low.confidence == 0.0
|
||||||
|
|
||||||
schema_high = VisionCaptureSchema(**valid_kwargs, confidence=1.0)
|
schema_high = VisionCaptureSchema(
|
||||||
|
**base_kwargs, confidence=1.0
|
||||||
|
)
|
||||||
assert schema_high.confidence == 1.0
|
assert schema_high.confidence == 1.0
|
||||||
|
|
||||||
def test_confidence_out_of_bounds_low(self, valid_kwargs):
|
def test_confidence_out_of_bounds_low(self, base_kwargs):
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
VisionCaptureSchema(**valid_kwargs, confidence=-0.1)
|
VisionCaptureSchema(
|
||||||
|
**base_kwargs, confidence=-0.1
|
||||||
|
)
|
||||||
|
|
||||||
def test_confidence_out_of_bounds_high(self, valid_kwargs):
|
def test_confidence_out_of_bounds_high(self, base_kwargs):
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
VisionCaptureSchema(**valid_kwargs, confidence=1.1)
|
VisionCaptureSchema(
|
||||||
|
**base_kwargs, confidence=1.1
|
||||||
|
)
|
||||||
|
|
||||||
def test_all_capture_types(self):
|
def test_all_capture_types(self):
|
||||||
for ct in VisionCaptureType:
|
for ct in VisionCaptureType:
|
||||||
@@ -188,9 +214,9 @@ class TestVisionCaptureSchema:
|
|||||||
)
|
)
|
||||||
assert schema.confidence_label == label
|
assert schema.confidence_label == label
|
||||||
|
|
||||||
def test_entities_list(self, valid_kwargs):
|
def test_entities_list(self, base_kwargs):
|
||||||
schema = VisionCaptureSchema(
|
schema = VisionCaptureSchema(
|
||||||
**valid_kwargs,
|
**base_kwargs,
|
||||||
entities=[
|
entities=[
|
||||||
{"type": "NUMBER", "value": "42", "confidence": 0.9},
|
{"type": "NUMBER", "value": "42", "confidence": 0.9},
|
||||||
{"type": "DATE", "value": "2024-01-15", "confidence": 0.8},
|
{"type": "DATE", "value": "2024-01-15", "confidence": 0.8},
|
||||||
@@ -200,9 +226,9 @@ class TestVisionCaptureSchema:
|
|||||||
assert schema.entities[0]["type"] == "NUMBER"
|
assert schema.entities[0]["type"] == "NUMBER"
|
||||||
assert schema.entities[1]["value"] == "2024-01-15"
|
assert schema.entities[1]["value"] == "2024-01-15"
|
||||||
|
|
||||||
def test_metadata_dict(self, valid_kwargs):
|
def test_metadata_dict(self, base_kwargs):
|
||||||
schema = VisionCaptureSchema(
|
schema = VisionCaptureSchema(
|
||||||
**valid_kwargs,
|
**base_kwargs,
|
||||||
metadata={"model": "qwen2.5-vl-3b", "processing_time": 2.3},
|
metadata={"model": "qwen2.5-vl-3b", "processing_time": 2.3},
|
||||||
)
|
)
|
||||||
assert schema.metadata["model"] == "qwen2.5-vl-3b"
|
assert schema.metadata["model"] == "qwen2.5-vl-3b"
|
||||||
@@ -218,37 +244,40 @@ class TestVisionReportSchema:
|
|||||||
"""Tests für VisionReportSchema — Zusammenfassung aller visuellen Evidenzen."""
|
"""Tests für VisionReportSchema — Zusammenfassung aller visuellen Evidenzen."""
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def valid_kwargs(self):
|
def base_kwargs(self):
|
||||||
return {
|
return {
|
||||||
"research_run_id": "run-uuid-001",
|
"research_run_id": "run-uuid-001",
|
||||||
"total_captures": 3,
|
"total_captures": 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_create_valid_report(self, valid_kwargs):
|
def test_create_valid_report(self, base_kwargs):
|
||||||
report = VisionReportSchema(**valid_kwargs)
|
report = VisionReportSchema(**base_kwargs)
|
||||||
assert report.research_run_id == "run-uuid-001"
|
assert report.research_run_id == "run-uuid-001"
|
||||||
assert report.total_captures == 3
|
assert report.total_captures == 3
|
||||||
assert report.captures == []
|
assert report.captures == []
|
||||||
assert report.entity_summary == {}
|
assert report.entity_summary == {}
|
||||||
assert report.summary_text == ""
|
assert report.summary_text == ""
|
||||||
|
|
||||||
def test_frozen(self, valid_kwargs):
|
def test_frozen(self, base_kwargs):
|
||||||
report = VisionReportSchema(**valid_kwargs)
|
report = VisionReportSchema(**base_kwargs)
|
||||||
with pytest.raises(Exception):
|
with pytest.raises(Exception):
|
||||||
report.research_run_id = "new-id"
|
report.research_run_id = "new-id"
|
||||||
|
|
||||||
def test_empty_research_run_id(self, valid_kwargs):
|
def test_empty_research_run_id(self):
|
||||||
with pytest.raises(ValidationError, match="research_run_id darf nicht leer sein"):
|
with pytest.raises(ValidationError, match="research_run_id darf nicht leer sein"):
|
||||||
VisionReportSchema(
|
VisionReportSchema(
|
||||||
**valid_kwargs,
|
|
||||||
research_run_id=" ",
|
research_run_id=" ",
|
||||||
|
total_captures=3,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_negative_total_captures(self, valid_kwargs):
|
def test_negative_total_captures(self):
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
VisionReportSchema(**valid_kwargs, total_captures=-1)
|
VisionReportSchema(
|
||||||
|
research_run_id="run-uuid-001",
|
||||||
|
total_captures=-1,
|
||||||
|
)
|
||||||
|
|
||||||
def test_with_captures(self, valid_kwargs):
|
def test_with_captures(self):
|
||||||
capture = VisionCaptureSchema(
|
capture = VisionCaptureSchema(
|
||||||
capture_type=VisionCaptureType.CHART,
|
capture_type=VisionCaptureType.CHART,
|
||||||
image_data_url="data:image/png;base64,xyz",
|
image_data_url="data:image/png;base64,xyz",
|
||||||
@@ -257,17 +286,18 @@ class TestVisionReportSchema:
|
|||||||
source_url="https://example.com",
|
source_url="https://example.com",
|
||||||
)
|
)
|
||||||
report = VisionReportSchema(
|
report = VisionReportSchema(
|
||||||
**valid_kwargs,
|
research_run_id="run-uuid-001",
|
||||||
total_captures=1,
|
total_captures=1,
|
||||||
captures=[capture],
|
captures=[capture],
|
||||||
)
|
)
|
||||||
assert len(report.captures) == 1
|
assert len(report.captures) == 1
|
||||||
assert report.captures[0].capture_type == VisionCaptureType.CHART
|
assert report.captures[0].capture_type == VisionCaptureType.CHART
|
||||||
|
|
||||||
def test_political_summary_rejected(self, valid_kwargs):
|
def test_political_summary_rejected(self):
|
||||||
with pytest.raises(ValidationError, match="politische Empfehlung"):
|
with pytest.raises(ValidationError, match="politische Empfehlung"):
|
||||||
VisionReportSchema(
|
VisionReportSchema(
|
||||||
**valid_kwargs,
|
research_run_id="run-uuid-001",
|
||||||
|
total_captures=0,
|
||||||
summary_text="Die Regierung sollte handeln.",
|
summary_text="Die Regierung sollte handeln.",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -281,7 +311,7 @@ class TestVisionRequestSchema:
|
|||||||
"""Tests für VisionRequestSchema — API-Request."""
|
"""Tests für VisionRequestSchema — API-Request."""
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def valid_kwargs(self):
|
def base_kwargs(self):
|
||||||
return {
|
return {
|
||||||
"research_run_id": "run-uuid-001",
|
"research_run_id": "run-uuid-001",
|
||||||
"source_id": "source-uuid-001",
|
"source_id": "source-uuid-001",
|
||||||
@@ -289,48 +319,74 @@ class TestVisionRequestSchema:
|
|||||||
"image_data": "data:image/png;base64,iVBORw0KGgoAAA==",
|
"image_data": "data:image/png;base64,iVBORw0KGgoAAA==",
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_create_valid_request(self, valid_kwargs):
|
def test_create_valid_request(self, base_kwargs):
|
||||||
request = VisionRequestSchema(**valid_kwargs)
|
request = VisionRequestSchema(**base_kwargs)
|
||||||
assert request.research_run_id == "run-uuid-001"
|
assert request.research_run_id == "run-uuid-001"
|
||||||
assert request.source_id == "source-uuid-001"
|
assert request.source_id == "source-uuid-001"
|
||||||
assert request.source_url == "https://example.com/image.png"
|
assert request.source_url == "https://example.com/image.png"
|
||||||
assert request.capture_type == VisionCaptureType.RAW_IMAGE
|
assert request.capture_type == VisionCaptureType.RAW_IMAGE
|
||||||
assert request.prompt is None
|
assert request.prompt is None
|
||||||
|
|
||||||
def test_frozen(self, valid_kwargs):
|
def test_frozen(self, base_kwargs):
|
||||||
request = VisionRequestSchema(**valid_kwargs)
|
request = VisionRequestSchema(**base_kwargs)
|
||||||
with pytest.raises(Exception):
|
with pytest.raises(Exception):
|
||||||
request.research_run_id = "new-id"
|
request.research_run_id = "new-id"
|
||||||
|
|
||||||
def test_defaults(self, valid_kwargs):
|
def test_defaults(self, base_kwargs):
|
||||||
request = VisionRequestSchema(**valid_kwargs)
|
request = VisionRequestSchema(**base_kwargs)
|
||||||
assert request.capture_type == VisionCaptureType.RAW_IMAGE
|
assert request.capture_type == VisionCaptureType.RAW_IMAGE
|
||||||
assert request.prompt is None
|
assert request.prompt is None
|
||||||
|
|
||||||
def test_with_prompt(self, valid_kwargs):
|
def test_with_prompt(self, base_kwargs):
|
||||||
request = VisionRequestSchema(
|
request = VisionRequestSchema(
|
||||||
**valid_kwargs,
|
**base_kwargs,
|
||||||
prompt="Extrahiere alle Zahlen und Daten aus dem Diagramm.",
|
prompt="Extrahiere alle Zahlen und Daten aus dem Diagramm.",
|
||||||
)
|
)
|
||||||
assert request.prompt == "Extrahiere alle Zahlen und Daten aus dem Diagramm."
|
assert request.prompt == "Extrahiere alle Zahlen und Daten aus dem Diagramm."
|
||||||
|
|
||||||
def test_empty_image_data(self, valid_kwargs):
|
def test_empty_image_data(self):
|
||||||
with pytest.raises(ValidationError, match="image_data darf nicht leer sein"):
|
with pytest.raises(ValidationError, match="image_data darf nicht leer sein"):
|
||||||
VisionRequestSchema(**valid_kwargs, image_data=" ")
|
VisionRequestSchema(
|
||||||
|
research_run_id="run-uuid-001",
|
||||||
|
source_id="source-uuid-001",
|
||||||
|
source_url="https://example.com/image.png",
|
||||||
|
image_data=" ",
|
||||||
|
)
|
||||||
|
|
||||||
def test_empty_source_url(self, valid_kwargs):
|
def test_empty_source_url(self):
|
||||||
with pytest.raises(ValidationError, match="source_url darf nicht leer sein"):
|
with pytest.raises(ValidationError, match="source_url darf nicht leer sein"):
|
||||||
VisionRequestSchema(**valid_kwargs, source_url=" ")
|
VisionRequestSchema(
|
||||||
|
research_run_id="run-uuid-001",
|
||||||
|
source_id="source-uuid-001",
|
||||||
|
source_url=" ",
|
||||||
|
image_data="data:image/png;base64,abc",
|
||||||
|
)
|
||||||
|
|
||||||
def test_empty_research_run_id(self, valid_kwargs):
|
def test_empty_research_run_id(self):
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
VisionRequestSchema(**valid_kwargs, research_run_id=" ")
|
VisionRequestSchema(
|
||||||
|
research_run_id=" ",
|
||||||
|
source_id="source-uuid-001",
|
||||||
|
source_url="https://example.com/image.png",
|
||||||
|
image_data="data:image/png;base64,abc",
|
||||||
|
)
|
||||||
|
|
||||||
def test_empty_source_id(self, valid_kwargs):
|
def test_empty_source_id(self):
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
VisionRequestSchema(**valid_kwargs, source_id=" ")
|
VisionRequestSchema(
|
||||||
|
research_run_id="run-uuid-001",
|
||||||
|
source_id=" ",
|
||||||
|
source_url="https://example.com/image.png",
|
||||||
|
image_data="data:image/png;base64,abc",
|
||||||
|
)
|
||||||
|
|
||||||
def test_capture_type_override(self, valid_kwargs):
|
def test_capture_type_override(self):
|
||||||
for ct in VisionCaptureType:
|
for ct in VisionCaptureType:
|
||||||
request = VisionRequestSchema(**valid_kwargs, capture_type=ct)
|
request = VisionRequestSchema(
|
||||||
|
research_run_id="run-uuid-001",
|
||||||
|
source_id="source-uuid-001",
|
||||||
|
source_url="https://example.com/image.png",
|
||||||
|
image_data="data:image/png;base64,abc",
|
||||||
|
capture_type=ct,
|
||||||
|
)
|
||||||
assert request.capture_type == ct
|
assert request.capture_type == ct
|
||||||
Reference in New Issue
Block a user