Skip to content

Traces

Traces is the core Python SDK client for conversation observability, audio analysis, speech-to-text transcription auditing, and BigQuery log reprocessing.

It provides programmatic access to: - Retrieving full conversation turn details, event timestamps, and tool calls. - Discovering user and agent turn audio recordings stored in GCS buckets. - Transcribing user speech turns with Gemini multimodal Flash / Flash-Lite models. - Evaluating Word Error Rate (WER) metrics against CES reference transcripts. - Filtering turns for non-English / foreign utterances. - Reprocessing transcription updates into cloned BigQuery conversation export tables in parallel.

Quick Example

1. Transcribe User Audio & Calculate WER

from cxas_scrapi.core.traces import Traces

app_name = "projects/my-project/locations/us/apps/my-app"
traces = Traces(app_name=app_name)

# 1. Discover user audio recordings from GCS for a conversation
user_audios = traces.get_user_audio_uris(conversation_id="conv-12345")
for item in user_audios:
    print(f"Turn {item['turn_index']}: {item['audio_uri']}")

# 2. Transcribe turns using Gemini Flash and compute WER metrics
results = traces.transcribe_user_turns(
    conversation_id="conv-12345",
    model="gemini-3.5-flash",
    only_non_english=False,
    max_workers=8,
)

for res in results:
    wer = res["wer_metrics"]
    print(
        f"Turn {res['turn_index']}: "
        f"WER={wer['wer']:.1%} "
        f"(Subs={wer['substitutions']}, Dels={wer['deletions']}, Ins={wer['insertions']})"
    )
    print(f"  CES Ref:    {res['reference_transcript']}")
    print(f"  Gemini Hyp: {res['gemini_transcript']}")

2. Reprocess Conversations into BigQuery Updates Table

# Reprocess user turn messages into a shared BigQuery updates table
reprocess_summary = traces.reprocess_transcriptions(
    conversation_id="conv-12345",
    model="gemini-3.5-flash",
    only_non_english=True,  # Only reprocess non-English/accented turns
    output_table="my_dataset.reprocessed_transcripts",
    max_workers=16,
)

print(f"Output Table: {reprocess_summary['output_table']}")
print(f"Reprocessed Turns: {reprocess_summary['total_turns_reprocessed']}")
print(f"Overall WER: {reprocess_summary['overall_wer']:.1%}")

3. Direct Word Error Rate (WER) Utility Usage

from cxas_scrapi.utils.tracing.audio_transcription import (
    AudioTranscriber,
    calculate_wer,
    contains_non_english,
    normalize_text,
)

# Calculate WER between reference and hypothesis
ref = "I would like to check my account balance please"
hyp = "I'd like to check my account balance please"

metrics = calculate_wer(ref, hyp, normalize=True)
print(f"WER: {metrics['wer']:.2%}")
print(f"Substitutions: {metrics['substitutions']}, Hits: {metrics['hits']}")

# Detect non-English characters
is_foreign = contains_non_english("Hola, ¿cómo estás?")
print(f"Contains non-English: {is_foreign}")

Reference

Traces

Traces(app_name, app_dir='.', env_file=None, environment=None, trace_config_path=None, creds_path=None, creds_dict=None, creds=None, scope=None, **kwargs)

Bases: Common

Orchestrates listing, fetching, enriching and analyzing conversations.

Construction is cheap: it loads the trace config and (optionally) the pulled-app config, and instantiates a ConversationHistory client. The heavier clients (Cloud Logging, Gemini, GCS) are lazily created on first use so a cxas trace list call does not require any of those packages to be reachable.

Source code in src/cxas_scrapi/core/traces.py
def __init__(
    self,
    app_name: str,
    app_dir: str = ".",
    env_file: str | None = None,
    environment: str | None = None,
    trace_config_path: str | None = None,
    creds_path: str | None = None,
    creds_dict: dict[str, str] | None = None,
    creds: Any = None,
    scope: list[str] | None = None,
    **kwargs: Any,
) -> None:
    super().__init__(
        creds_path=creds_path,
        creds_dict=creds_dict,
        creds=creds,
        scope=scope,
        app_name=app_name,
        **kwargs,
    )
    self.history = ConversationHistory(
        app_name=app_name,
        creds_path=creds_path,
        creds_dict=creds_dict,
        creds=creds,
        scope=scope,
        **kwargs,
    )
    self.trace_config = TraceConfig.load(trace_config_path)
    # AppConfig is optional — `cxas trace list` works without a pulled app.
    self.app_config: AppConfig | None
    try:
        self.app_config = AppConfig.load(
            app_dir=app_dir,
            env_file=env_file,
            environment=environment,
        )
    except FileNotFoundError as e:
        logger.info(
            f"No local app.json found ({e}); audio/log discovery will "
            f"depend on `--bucket-override` / `trace.yaml` settings only."
        )
        self.app_config = None

get_user_audio_uris

get_user_audio_uris(conversation_id)

Returns a mapping of {turn_index: gcs_uri} for user turn recordings.

Discovers audio files for the given conversation and extracts the turn number from filenames matching user-turn-<N>.wav.

Parameters:

Name Type Description Default
conversation_id str

The conversation ID.

required

Returns:

Type Description
dict[int, str]

Dict mapping integer turn_index to GCS URI.

Source code in src/cxas_scrapi/core/traces.py
def get_user_audio_uris(self, conversation_id: str) -> dict[int, str]:
    """Returns a mapping of {turn_index: gcs_uri} for user turn recordings.

    Discovers audio files for the given conversation and extracts the
    turn number from filenames matching `user-turn-<N>.wav`.

    Args:
        conversation_id: The conversation ID.

    Returns:
        Dict mapping integer turn_index to GCS URI.
    """
    files = self.list_audio_files(conversation_id)
    user_audio_map: dict[int, str] = {}
    for f in files:
        fname = f.split("/")[-1]
        match = re.search(r"user-turn-(\d+)\.wav$", fname)
        if match:
            turn_idx = int(match.group(1))
            user_audio_map[turn_idx] = f
    return dict(sorted(user_audio_map.items()))

transcribe_user_turns

transcribe_user_turns(conversation_id, model_name=DEFAULT_TRANSCRIPTION_MODEL, only_non_english=False, prompt=None, max_workers=8)

Transcribes user audio recordings for a trace and evaluates WER.

Parameters:

Name Type Description Default
conversation_id str

Conversation ID of the trace.

required
model_name str

Gemini model name for transcription (default: gemini-2.5-flash).

DEFAULT_TRANSCRIPTION_MODEL
only_non_english bool

If True, only reprocesses turns that contain non-English characters.

False
prompt str | None

Optional custom prompt instruction for transcription.

None
max_workers int

Maximum worker threads for parallel transcription.

8

Returns:

Type Description
list[dict[str, Any]]

List of dictionaries for each user turn containing transcription

list[dict[str, Any]]

and WER metrics.

Source code in src/cxas_scrapi/core/traces.py
def transcribe_user_turns(
    self,
    conversation_id: str,
    model_name: str = DEFAULT_TRANSCRIPTION_MODEL,
    only_non_english: bool = False,
    prompt: str | None = None,
    max_workers: int = 8,
) -> list[dict[str, Any]]:
    """Transcribes user audio recordings for a trace and evaluates WER.

    Args:
        conversation_id: Conversation ID of the trace.
        model_name: Gemini model name for transcription (default:
            gemini-2.5-flash).
        only_non_english: If True, only reprocesses turns that contain
            non-English characters.
        prompt: Optional custom prompt instruction for transcription.
        max_workers: Maximum worker threads for parallel transcription.

    Returns:
        List of dictionaries for each user turn containing transcription
        and WER metrics.
    """
    normalized = self.get_normalized(conversation_id)
    user_audio_map = self.get_user_audio_uris(conversation_id)

    transcriber = AudioTranscriber(
        project_id=self.project_id or "",
        credentials=self.creds,
        model_name=model_name,
        prompt=prompt,
    )

    user_entries = [
        e for e in normalized.get("entries", []) if e.get("kind") == "user"
    ]

    def _eval_user_entry(entry: dict[str, Any]) -> dict[str, Any] | None:
        turn_idx = entry.get("turn", 0)
        ces_text = entry.get("text") or ""
        has_non_english = contains_non_english(ces_text)

        audio_uri = user_audio_map.get(turn_idx)
        if not audio_uri:
            return None

        if only_non_english and not has_non_english:
            return {
                "conversation_id": conversation_id,
                "turn_index": turn_idx,
                "audio_uri": audio_uri,
                "ces_transcript": ces_text,
                "gemini_transcript": None,
                "contains_non_english": False,
                "reprocessed": False,
                "wer": None,
                "substitutions": 0,
                "deletions": 0,
                "insertions": 0,
                "hits": 0,
                "reference_words": len(normalize_text(ces_text)),
                "hypothesis_words": 0,
            }

        eval_res = transcriber.evaluate_turn(
            reference_transcript=ces_text,
            audio_source=audio_uri,
            turn_index=turn_idx,
            conversation_id=conversation_id,
            prompt=prompt,
        )
        eval_res["audio_uri"] = audio_uri
        eval_res["reprocessed"] = True
        return eval_res

    results: list[dict[str, Any]] = []
    with ThreadPoolExecutor(max_workers=max_workers) as ex:
        futures = [ex.submit(_eval_user_entry, e) for e in user_entries]
        for fut in as_completed(futures):
            res = fut.result()
            if res is not None:
                results.append(res)

    results.sort(key=lambda x: int(x.get("turn_index") or 0))
    return results

reprocess_transcriptions

reprocess_transcriptions(conversation_id=None, output_table=None, source_table=None, bq_dataset=None, bq_project=None, model_name=DEFAULT_TRANSCRIPTION_MODEL, only_non_english=False, dry_run=False, limit=None, prompt=None, max_workers=8)

Reprocesses user speech transcriptions from GCS audio, computes WER, and appends updated turn records to a BigQuery destination table.

Parameters:

Name Type Description Default
conversation_id str | None

Optional specific conversation ID. If omitted, reprocesses conversations found in the BigQuery source table.

None
output_table str | None

Target BigQuery table name or qualified reference (e.g. dataset.reprocessed_transcripts). Defaults to {dataset}.reprocessed_transcripts.

None
source_table str | None

Source BigQuery table name containing conversations. Defaults to the app's CES BigQuery export table.

None
bq_dataset str | None

Optional BigQuery dataset override.

None
bq_project str | None

Optional BigQuery project override.

None
model_name str

Gemini Flash / Flash-Lite model name (default: gemini-2.5-flash).

DEFAULT_TRANSCRIPTION_MODEL
only_non_english bool

If True, only reprocesses turns that contain non-English characters.

False
dry_run bool

If True, calculates transcriptions and WER without mutating BigQuery.

False
limit int | None

Maximum number of conversations to process.

None
prompt str | None

Optional transcription prompt override.

None
max_workers int

Maximum worker threads for concurrent processing.

8

Returns:

Type Description
dict[str, Any]

Summary dictionary with aggregate metrics and per-turn details.

Source code in src/cxas_scrapi/core/traces.py
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
def reprocess_transcriptions(
    self,
    conversation_id: str | None = None,
    output_table: str | None = None,
    source_table: str | None = None,
    bq_dataset: str | None = None,
    bq_project: str | None = None,
    model_name: str = DEFAULT_TRANSCRIPTION_MODEL,
    only_non_english: bool = False,
    dry_run: bool = False,
    limit: int | None = None,
    prompt: str | None = None,
    max_workers: int = 8,
) -> dict[str, Any]:
    """Reprocesses user speech transcriptions from GCS audio, computes WER,
    and appends updated turn records to a BigQuery destination table.

    Args:
        conversation_id: Optional specific conversation ID. If omitted,
            reprocesses conversations found in the BigQuery source table.
        output_table: Target BigQuery table name or qualified reference
            (e.g. `dataset.reprocessed_transcripts`). Defaults to
            `{dataset}.reprocessed_transcripts`.
        source_table: Source BigQuery table name containing conversations.
            Defaults to the app's CES BigQuery export table.
        bq_dataset: Optional BigQuery dataset override.
        bq_project: Optional BigQuery project override.
        model_name: Gemini Flash / Flash-Lite model name (default:
            gemini-2.5-flash).
        only_non_english: If True, only reprocesses turns that contain
            non-English characters.
        dry_run: If True, calculates transcriptions and WER without
            mutating BigQuery.
        limit: Maximum number of conversations to process.
        prompt: Optional transcription prompt override.
        max_workers: Maximum worker threads for concurrent processing.

    Returns:
        Summary dictionary with aggregate metrics and per-turn details.
    """
    bq_settings = self._get_remote_bigquery_export_settings()
    proj = (
        bq_project
        or (bq_settings.get("project") if bq_settings else None)
        or self.project_id
    )
    dataset = bq_dataset or (
        bq_settings.get("dataset") if bq_settings else None
    )
    src_table_name = source_table or (self.app_name or "").split("/")[-1]

    if not proj or not dataset:
        raise ValueError(
            "BigQuery project and dataset could not be determined. "
            "Ensure BigQuery export is enabled on the app or pass "
            "`bq_dataset` / `bq_project`."
        )

    if "." in src_table_name:
        src_table_ref = src_table_name
    else:
        src_table_ref = f"{proj}.{dataset}.{src_table_name}"

    # Resolve output destination table
    if output_table:
        if "." in output_table:
            parts = output_table.split(".")
            if len(parts) == 2:
                dst_table_ref = f"{proj}.{parts[0]}.{parts[1]}"
            else:
                dst_table_ref = output_table
        else:
            dst_table_ref = f"{proj}.{dataset}.{output_table}"
    else:
        dst_table_ref = f"{proj}.{dataset}.reprocessed_transcripts"

    bq_client = self.get_bigquery_client(project_id=proj)

    # Ensure output table exists if not dry_run
    if not dry_run:
        try:
            bq_client.get_table(dst_table_ref)
        except Exception:
            try:
                table_obj = bigquery.Table(
                    dst_table_ref, schema=REPROCESSED_TRANSCRIPTS_SCHEMA
                )
                bq_client.create_table(table_obj, exists_ok=True)
                logger.info(
                    f"Created BigQuery destination table {dst_table_ref}"
                )
            except Exception as ex:
                logger.warning(
                    "Could not auto-create destination table %s: %s",
                    dst_table_ref,
                    ex,
                )

    # Query rows from BigQuery source table
    if conversation_id:
        query = (
            f"SELECT * FROM `{src_table_ref}` "
            f"WHERE conversation_id = @conv_id ORDER BY turn_index"
        )
        job_config = bigquery.QueryJobConfig(
            query_parameters=[
                bigquery.ScalarQueryParameter(
                    "conv_id", "STRING", conversation_id
                )
            ]
        )
    else:
        query = (
            f"SELECT * FROM `{src_table_ref}` "
            f"ORDER BY create_time DESC, turn_index ASC"
        )
        job_config = None

    rows = list(bq_client.query(query, job_config=job_config).result())

    # Group rows by conversation_id
    conv_rows_map: dict[str, list[dict[str, Any]]] = {}
    for r in rows:
        r_dict = dict(r.items())
        cid = r_dict.get("conversation_id", "")
        conv_rows_map.setdefault(cid, []).append(r_dict)

    selected_cids = list(conv_rows_map.keys())
    if limit and not conversation_id:
        selected_cids = selected_cids[:limit]

    transcriber = AudioTranscriber(
        project_id=proj,
        credentials=self.creds,
        model_name=model_name,
    )

    turn_results: list[dict[str, Any]] = []
    total_user_turns_inspected = 0
    total_turns_reprocessed = 0
    total_substitutions = 0
    total_deletions = 0
    total_insertions = 0
    total_hits = 0
    total_ref_words = 0
    total_hyp_words = 0
    turn_wers: list[float] = []

    # Discover audio files in parallel for all selected conversations
    cid_audio_map: dict[str, dict[int, str]] = {}
    with ThreadPoolExecutor(max_workers=max_workers) as ex:
        fut_to_cid = {
            ex.submit(self.get_user_audio_uris, cid): cid
            for cid in selected_cids
        }
        for fut in as_completed(fut_to_cid):
            c_id = fut_to_cid[fut]
            try:
                cid_audio_map[c_id] = fut.result()
            except Exception as e:
                logger.warning(
                    f"Failed to fetch audio files for {c_id}: {e}"
                )
                cid_audio_map[c_id] = {}

    def _process_turn(
        cid: str, row_dict: dict[str, Any]
    ) -> dict[str, Any] | None:
        t_idx = row_dict.get("turn_index", 0)
        messages = row_dict.get("messages", [])

        # Find user messages
        user_msgs = [m for m in messages if m.get("role") == "user"]
        if not user_msgs:
            return None

        # Extract CES transcript chunk
        ces_transcript = ""
        has_transcript_chunk = False
        for um in user_msgs:
            for c in um.get("chunks", []):
                if "transcript" in c:
                    ces_transcript = c.get("transcript") or ""
                    has_transcript_chunk = True
                    break
            if has_transcript_chunk:
                break

        user_audio_map = cid_audio_map.get(cid, {})
        has_non_english = contains_non_english(ces_transcript)

        if only_non_english and not has_non_english:
            return {
                "conversation_id": cid,
                "turn_index": t_idx,
                "audio_uri": user_audio_map.get(t_idx),
                "ces_transcript": ces_transcript,
                "gemini_transcript": None,
                "contains_non_english": False,
                "reprocessed": False,
                "appended_to_bq": False,
                "wer": None,
            }

        audio_uri = user_audio_map.get(t_idx)
        if not audio_uri:
            return {
                "conversation_id": cid,
                "turn_index": t_idx,
                "audio_uri": None,
                "ces_transcript": ces_transcript,
                "gemini_transcript": None,
                "contains_non_english": has_non_english,
                "reprocessed": False,
                "appended_to_bq": False,
                "wer": None,
                "error": "Audio file not found in GCS",
            }

        # Transcribe with Gemini
        try:
            gemini_transcript = transcriber.transcribe(
                audio_uri, prompt=prompt
            )
            wer_metrics = calculate_wer(
                reference=ces_transcript,
                hypothesis=gemini_transcript,
                normalize=True,
            )
        except Exception as ex:
            logger.warning(
                f"Transcription failed for {cid} turn {t_idx}: {ex}"
            )
            return {
                "conversation_id": cid,
                "turn_index": t_idx,
                "audio_uri": audio_uri,
                "ces_transcript": ces_transcript,
                "gemini_transcript": None,
                "contains_non_english": has_non_english,
                "reprocessed": False,
                "appended_to_bq": False,
                "wer": None,
                "error": str(ex),
            }

        return {
            "conversation_id": cid,
            "turn_index": t_idx,
            "audio_uri": audio_uri,
            "ces_transcript": ces_transcript,
            "gemini_transcript": gemini_transcript,
            "contains_non_english": has_non_english,
            "reprocessed": True,
            "appended_to_bq": False,
            **wer_metrics,
        }

    # Submit turn processing jobs in parallel
    tasks: list[tuple[str, dict[str, Any]]] = []
    for cid in selected_cids:
        for row_dict in conv_rows_map[cid]:
            tasks.append((cid, row_dict))

    with ThreadPoolExecutor(max_workers=max_workers) as ex:
        futures = [ex.submit(_process_turn, cid, r) for cid, r in tasks]
        for fut in as_completed(futures):
            res = fut.result()
            if res is not None:
                turn_results.append(res)

    # Sort results deterministically by conversation_id and turn_index
    turn_results.sort(
        key=lambda t: (
            str(t.get("conversation_id", "")),
            int(t.get("turn_index") or 0),
        )
    )

    # Append reprocessed turns to BigQuery destination table
    rows_to_insert = []
    now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
    for t in turn_results:
        if t.get("reprocessed") and t.get("gemini_transcript") is not None:
            rows_to_insert.append(
                {
                    "conversation_id": t["conversation_id"],
                    "turn_index": int(t["turn_index"]),
                    "audio_uri": t.get("audio_uri"),
                    "original_transcript": t.get("ces_transcript"),
                    "updated_transcript": t.get("gemini_transcript"),
                    "wer": t.get("wer"),
                    "substitutions": t.get("substitutions"),
                    "deletions": t.get("deletions"),
                    "insertions": t.get("insertions"),
                    "hits": t.get("hits"),
                    "is_non_english": t.get("contains_non_english"),
                    "model": model_name,
                    "reprocessed_at": now_iso,
                }
            )

    if not dry_run and rows_to_insert:
        try:
            errors = bq_client.insert_rows_json(
                dst_table_ref, rows_to_insert
            )
            if errors:
                logger.error(
                    f"Failed to append rows to {dst_table_ref}: {errors}"
                )
            else:
                logger.info(
                    "Appended %s updated turns to %s",
                    len(rows_to_insert),
                    dst_table_ref,
                )
                for t in turn_results:
                    if (
                        t.get("reprocessed")
                        and t.get("gemini_transcript") is not None
                    ):
                        t["appended_to_bq"] = True
        except Exception as bq_ex:
            logger.error(
                "Failed to insert rows into BigQuery destination table"
                " %s: %s",
                dst_table_ref,
                bq_ex,
            )

    for t in turn_results:
        total_user_turns_inspected += 1
        if t.get("reprocessed"):
            total_turns_reprocessed += 1
            total_substitutions += t.get("substitutions", 0)
            total_deletions += t.get("deletions", 0)
            total_insertions += t.get("insertions", 0)
            total_hits += t.get("hits", 0)
            total_ref_words += t.get("reference_words", 0)
            total_hyp_words += t.get("hypothesis_words", 0)
            if t.get("wer") is not None:
                turn_wers.append(t["wer"])

    overall_errors = (
        total_substitutions + total_deletions + total_insertions
    )
    overall_wer = (
        round(overall_errors / total_ref_words, 4)
        if total_ref_words > 0
        else 0.0
    )
    average_turn_wer = (
        round(sum(turn_wers) / len(turn_wers), 4) if turn_wers else 0.0
    )

    return {
        "source_table": src_table_ref,
        "output_table": dst_table_ref,
        "dry_run": dry_run,
        "model_used": model_name,
        "only_non_english": only_non_english,
        "total_user_turns_inspected": total_user_turns_inspected,
        "total_turns_reprocessed": total_turns_reprocessed,
        "overall_wer": overall_wer,
        "average_turn_wer": average_turn_wer,
        "total_substitutions": total_substitutions,
        "total_deletions": total_deletions,
        "total_insertions": total_insertions,
        "total_hits": total_hits,
        "total_reference_words": total_ref_words,
        "total_hypothesis_words": total_hyp_words,
        "turns": turn_results,
    }

AudioTranscriber

AudioTranscriber(project_id, credentials=None, model_name=DEFAULT_TRANSCRIPTION_MODEL, location='global', **kwargs)

Handles audio transcription using Gemini Flash / Flash-Lite models and

computes transcription accuracy metrics.

Initializes the AudioTranscriber.

Parameters:

Name Type Description Default
project_id str

Google Cloud project ID.

required
credentials Any

Optional credentials object.

None
model_name str

Gemini model name (default: gemini-2.5-flash).

DEFAULT_TRANSCRIPTION_MODEL
location str

Vertex AI location.

'global'
**kwargs Any

Additional keyword arguments.

{}
Source code in src/cxas_scrapi/utils/tracing/audio_transcription.py
def __init__(
    self,
    project_id: str,
    credentials: Any = None,
    model_name: str = DEFAULT_TRANSCRIPTION_MODEL,
    location: str = "global",
    **kwargs: Any,
) -> None:
    """Initializes the AudioTranscriber.

    Args:
        project_id: Google Cloud project ID.
        credentials: Optional credentials object.
        model_name: Gemini model name (default: gemini-2.5-flash).
        location: Vertex AI location.
        **kwargs: Additional keyword arguments.
    """
    self.project_id = project_id
    self.credentials = credentials
    self.model_name = model_name
    self.location = location
    self.gemini = GeminiGenerate(
        project_id=self.project_id,
        credentials=self.credentials,
        model_name=self.model_name,
        location=self.location,
        **kwargs,
    )

calculate_wer

calculate_wer(reference, hypothesis, normalize=True)

Calculates Word Error Rate (WER) between reference and hypothesis text.

Computes standard word-level Levenshtein edit distance

WER = (Substitutions + Deletions + Insertions) / Reference_Word_Count

Parameters:

Name Type Description Default
reference str

Ground truth / baseline transcript (e.g. CES transcription).

required
hypothesis str

Predicted / generated transcript (e.g. Gemini transcription).

required
normalize bool

Whether to normalize case, punctuation, and whitespace before computing WER. Defaults to True.

True

Returns:

Type Description
dict[str, Any]

Dictionary containing: - wer: float (rounded to 4 decimal places) - substitutions: int - deletions: int - insertions: int - hits: int (matching words) - reference_words: int - hypothesis_words: int - reference_tokens: list[str] - hypothesis_tokens: list[str]

Source code in src/cxas_scrapi/utils/tracing/audio_transcription.py
def calculate_wer(
    reference: str,
    hypothesis: str,
    normalize: bool = True,
) -> dict[str, Any]:
    """Calculates Word Error Rate (WER) between reference and hypothesis text.

    Computes standard word-level Levenshtein edit distance:
        WER = (Substitutions + Deletions + Insertions) / Reference_Word_Count

    Args:
        reference: Ground truth / baseline transcript (e.g. CES transcription).
        hypothesis: Predicted / generated transcript (e.g. Gemini
            transcription).
        normalize: Whether to normalize case, punctuation, and whitespace
            before computing WER. Defaults to True.

    Returns:
        Dictionary containing:
            - wer: float (rounded to 4 decimal places)
            - substitutions: int
            - deletions: int
            - insertions: int
            - hits: int (matching words)
            - reference_words: int
            - hypothesis_words: int
            - reference_tokens: list[str]
            - hypothesis_tokens: list[str]
    """
    if normalize:
        ref_words = normalize_text(reference)
        hyp_words = normalize_text(hypothesis)
    else:
        ref_words = reference.split() if reference else []
        hyp_words = hypothesis.split() if hypothesis else []

    n = len(ref_words)
    m = len(hyp_words)

    if n == 0:
        if m == 0:
            return {
                "wer": 0.0,
                "substitutions": 0,
                "deletions": 0,
                "insertions": 0,
                "hits": 0,
                "reference_words": 0,
                "hypothesis_words": 0,
                "reference_tokens": ref_words,
                "hypothesis_tokens": hyp_words,
            }
        return {
            "wer": 1.0,
            "substitutions": 0,
            "deletions": 0,
            "insertions": m,
            "hits": 0,
            "reference_words": 0,
            "hypothesis_words": m,
            "reference_tokens": ref_words,
            "hypothesis_tokens": hyp_words,
        }

    # Dynamic Programming Matrix for Levenshtein Distance
    dp = [[0] * (m + 1) for _ in range(n + 1)]
    for i in range(n + 1):
        dp[i][0] = i
    for j in range(m + 1):
        dp[0][j] = j

    for i in range(1, n + 1):
        for j in range(1, m + 1):
            if ref_words[i - 1] == hyp_words[j - 1]:
                dp[i][j] = dp[i - 1][j - 1]
            else:
                dp[i][j] = min(
                    dp[i - 1][j - 1] + 1,  # substitution
                    dp[i - 1][j] + 1,  # deletion
                    dp[i][j - 1] + 1,  # insertion
                )

    # Backtrack to count operations
    i, j = n, m
    substitutions = 0
    deletions = 0
    insertions = 0
    hits = 0

    while i > 0 or j > 0:
        if i > 0 and j > 0 and ref_words[i - 1] == hyp_words[j - 1]:
            hits += 1
            i -= 1
            j -= 1
        elif i > 0 and j > 0 and dp[i][j] == dp[i - 1][j - 1] + 1:
            substitutions += 1
            i -= 1
            j -= 1
        elif i > 0 and dp[i][j] == dp[i - 1][j] + 1:
            deletions += 1
            i -= 1
        elif j > 0 and dp[i][j] == dp[i][j - 1] + 1:
            insertions += 1
            j -= 1
        elif i > 0 and j > 0:
            substitutions += 1
            i -= 1
            j -= 1
        elif i > 0:
            deletions += 1
            i -= 1
        else:
            insertions += 1
            j -= 1

    total_errors = substitutions + deletions + insertions
    wer = round(total_errors / n, 4)

    return {
        "wer": wer,
        "substitutions": substitutions,
        "deletions": deletions,
        "insertions": insertions,
        "hits": hits,
        "reference_words": n,
        "hypothesis_words": m,
        "reference_tokens": ref_words,
        "hypothesis_tokens": hyp_words,
    }

normalize_text

normalize_text(text)

Normalizes text and tokenizes it into a list of word tokens.

Applies Unicode NFKC normalization, lowercase folding, and splits into alphanumeric/word tokens while preserving international/unicode characters and apostrophes within words (e.g. "don't", "it's").

Parameters:

Name Type Description Default
text str

The raw input string.

required

Returns:

Type Description
list[str]

List of normalized word tokens.

Source code in src/cxas_scrapi/utils/tracing/audio_transcription.py
def normalize_text(text: str) -> list[str]:
    """Normalizes text and tokenizes it into a list of word tokens.

    Applies Unicode NFKC normalization, lowercase folding, and splits into
    alphanumeric/word tokens while preserving international/unicode characters
    and apostrophes within words (e.g. "don't", "it's").

    Args:
        text: The raw input string.

    Returns:
        List of normalized word tokens.
    """
    if not text:
        return []
    # Normalize unicode representations
    normalized = unicodedata.normalize("NFKC", text).lower()
    # Find all words, including non-ASCII letters and numbers
    # Preserves unicode letters (e.g. accented Latin, Cyrillic, CJK, etc.)
    # and words containing internal apostrophes (e.g. don't, it's)
    words = re.findall(
        r"[\w\u00C0-\u024F\u1E00-\u1EFF\u0400-\u04FF\u4E00-\u9FFF]+(?:'[\w\u00C0-\u024F\u1E00-\u1EFF\u0400-\u04FF\u4E00-\u9FFF]+)?",
        normalized,
    )
    return words

contains_non_english

contains_non_english(text)

Checks if text contains non-English / non-ASCII characters.

Detects characters with code point > 127 (such as accented characters, emojis, or non-Latin alphabets) which indicate non-English speech or transcription anomalies.

Parameters:

Name Type Description Default
text str

The text to inspect.

required

Returns:

Type Description
bool

True if any character is non-ASCII (code point > 127).

Source code in src/cxas_scrapi/utils/tracing/audio_transcription.py
def contains_non_english(text: str) -> bool:
    """Checks if text contains non-English / non-ASCII characters.

    Detects characters with code point > 127 (such as accented characters,
    emojis, or non-Latin alphabets) which indicate non-English speech or
    transcription anomalies.

    Args:
        text: The text to inspect.

    Returns:
        True if any character is non-ASCII (code point > 127).
    """
    if not text:
        return False
    return any(ord(char) > 127 for char in text)