feat(stage11): audio integration — STT for interviews, podcasts, press conferences with timestamped claims
This commit is contained in:
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")
|
||||
@classmethod
|
||||
def summary_not_political(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
return v
|
||||
import re
|
||||
|
||||
forbidden = re.compile(
|
||||
r"((Regierung|Bundesregierung)\s+(muss|sollte)\s+(handeln|unterstützen)|"
|
||||
r"(sollte\s+(Regierung|Bundesregierung)\s+(handeln|unterstützen)|"
|
||||
r"muss\s+(geändert|eingesetzt|gestürzt))",
|
||||
re.IGNORECASE,
|
||||
@@ -256,6 +259,20 @@ class VisionRequestSchema(BaseModel):
|
||||
min_length=1,
|
||||
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(
|
||||
default=VisionCaptureType.RAW_IMAGE,
|
||||
description="Art der visuellen Erfassung.",
|
||||
|
||||
Reference in New Issue
Block a user