From podium-pack
Ingest Podium call transcripts with PII redaction, language detection, and idempotent dedup — durable layer between Podium's webhook and a downstream RAG/LLM queue.
How this skill is triggered — by the user, by Claude, or both
Slash command
/podium-pack:podium-call-transcript-pipelineThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
Ingest Podium phone-call transcripts and stage them on a downstream queue so an LLM (with RAG context) can assist the team answering the phone. This is not a real-time transcription tool — Podium emits transcripts on a webhook minutes-to-hours after the call ends, and the design assumes that asynchrony. The skill is the durable layer between Podium and the RAG bridge: webhook lands, transcript ...
Ingest Podium phone-call transcripts and stage them on a downstream queue so an LLM (with RAG context) can assist the team answering the phone. This is not a real-time transcription tool — Podium emits transcripts on a webhook minutes-to-hours after the call ends, and the design assumes that asynchrony. The skill is the durable layer between Podium and the RAG bridge: webhook lands, transcript is verified and de-duplicated, PII is redacted on ingest, speaker structure is preserved, language is detected, and a chunked record is enqueued for the next stage.
The six production failures this skill prevents:
call.transcript.partial before call.transcript.completed. Naive handlers store the partial as final, and the LLM downstream sees a truncated transcript. The ingest must key on (transcript_id, event_type) and only promote a record to "final" on a completed event.This skill does not authenticate to Podium directly. Two distinct auth paths are involved and both are consumed by reference from sibling skills — never re-implemented:
podium-webhook-reliability::verify_webhook(raw, signature). The webhook secret lives in the verifier's config. The handler fails closed if the verifier is not importable.podium-auth::PodiumAuth.get_token(). Credentials live in podium-auth's secret store.The pipeline inherits the auth posture of both skills. Operator checklist when installing:
podium-auth and podium-webhook-reliability are both installed and configured..gitignore to exclude the inbox database (*.db) and the redaction audit log (redactions.jsonl).references/implementation.md) to confirm no inline credentials leaked.PODIUM_TRANSCRIPT_INBOX_PATH to a writable path with mode 0600 ownership.podium-auth skill installed (consumed for outbound API auth)podium-webhook-reliability skill installed (consumed for HMAC verification)podium-rate-limit-survival skill installed (consumed by the fallback poller)langdetect (default) or fasttext-langdetect for language detectionpresidio-analyzer + presidio-anonymizer for high-recall PII, or the bundled regex layer aloneBuild in this order. Each section neutralizes one production failure mode.
The webhook handler returns 200 fast and does all transcript work asynchronously. The handler's only synchronous job is verify-and-store-raw; everything else happens out-of-band in the processor.
import json, time
from fastapi import FastAPI, Request, HTTPException
from podium_webhook_reliability import verify_webhook # consumed by reference
from podium_call_transcript_pipeline import inbox
app = FastAPI()
@app.post("/podium/transcripts")
async def transcript_webhook(request: Request):
raw = await request.body()
sig = request.headers.get("podium-signature", "")
if not verify_webhook(raw, sig):
raise HTTPException(401, "invalid signature")
event = json.loads(raw)
if not event.get("type", "").startswith("call.transcript."):
return {"status": "ignored"}
# Durable write happens BEFORE returning 200. If this fails, return 5xx so Podium retries.
inbox.insert(
transcript_id=event["data"]["transcript_id"],
event_type=event["type"],
received_at=time.time(),
raw_payload=raw,
)
return {"status": "accepted"}
Podium emits these event types on a single call:
| Event type | Meaning | Handling |
|---|---|---|
call.ended | Audio capture complete | Note arrival; no transcript yet |
call.transcript.partial | Best-effort transcript while final generates | Store as partial; never promote to final |
call.transcript.completed | Final transcript ready | Promote to final; supersedes any partial |
call.transcript.failed | Transcription failed | Record failure; alert if call duration was material |
The inbox table is keyed on (transcript_id, event_type). A separate transcripts table is keyed on transcript_id alone. A completed event always supersedes a partial for the same transcript_id. A late-arriving partial after completed is ignored — the processor checks current status before writing.
Detect language before redaction (redaction patterns are language-aware downstream). The default policy: English transcripts proceed to the standard RAG queue; non-English transcripts go to a separate queue with a translation step inserted.
from langdetect import detect_langs, DetectorFactory
DetectorFactory.seed = 0 # deterministic detection across runs
def detect_transcript_language(text: str) -> tuple[str, float]:
if len(text.strip()) < 20:
return ("und", 0.0) # too short to detect reliably
try:
top = detect_langs(text)[0]
return (top.lang, top.prob)
except Exception:
return ("und", 0.0)
Routing rule: English with confidence ≥ 0.85 → queue:rag.transcripts.en; confidence < 0.50 → queue:rag.transcripts.review (human review); otherwise → per-language queue.
Redaction is non-optional and happens before the transcript is written to the outbound queue. The redaction is auditable — for every redaction the system records category, character offsets, and the rule's id. Conservative regex layer for high-precision categories; presidio/spaCy for lower-precision recall categories (names, addresses).
import re
from dataclasses import dataclass
@dataclass
class Redaction:
category: str
rule_id: str
start: int
end: int
PATTERNS = [
("CREDIT_CARD", "card_luhn_16", re.compile(r"\b(?:\d[ -]?){13,19}\b")),
("PHONE", "phone_intl", re.compile(r"\+?\d{1,3}[ -]?\(?\d{2,4}\)?[ -]?\d{3,4}[ -]?\d{3,4}")),
("EMAIL", "email_basic", re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")),
("SSN_US", "ssn_us", re.compile(r"\b\d{3}-\d{2}-\d{4}\b")),
]
def luhn_valid(s: str) -> bool:
digits = [int(c) for c in s if c.isdigit()]
if not 13 <= len(digits) <= 19: return False
checksum = 0
for pos, d in enumerate(reversed(digits)):
if pos % 2 == 1:
d *= 2
if d > 9: d -= 9
checksum += d
return checksum % 10 == 0
Wire presidio after the regex pass and union both result lists into one audit log keyed by transcript_id. Never ship a redaction module that silently swallows detections — every detection must either redact-and-log or pass-through-and-log with a documented reason.
The webhook writes to a durable store (SQLite default) before acking. A separate processor moves records from the inbox to the outbound queue. Failed queue writes stay in the inbox with attempt_count and next_attempt_at — the processor's next scan picks them up.
SCHEMA = """
CREATE TABLE IF NOT EXISTS inbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transcript_id TEXT NOT NULL,
event_type TEXT NOT NULL,
received_at REAL NOT NULL,
raw_payload BLOB NOT NULL,
processed_at REAL,
enqueued_at REAL,
attempt_count INTEGER NOT NULL DEFAULT 0,
next_attempt_at REAL,
last_error TEXT,
UNIQUE(transcript_id, event_type)
);
"""
# UNIQUE(transcript_id, event_type) makes webhook redelivery idempotent.
# 3600 seconds = 1h cap on exponential backoff; 12-attempt budget = ~4 days before dead-letter.
Failed queue writes increment attempt_count, set next_attempt_at = now + min(2^attempts, 3600). After 12 attempts the row moves to inbox_deadletter and pages on-call.
Podium's transcript JSON has a segments[] array with {speaker_role, start_ms, end_ms, text} per utterance. Two rules:
speakers: [...].# target_tokens=1500 fits a chunk inside a 4k context with room for RAG-retrieved
# companion chunks plus prompt scaffolding. overlap_tokens=200 preserves cross-chunk
# context without doubling the token budget.
def chunk_transcript(segments, target_tokens=1500, overlap_tokens=200):
chunks, current = [], Chunk(chunk_index=0)
for seg in segments:
seg_tokens = estimate_tokens(seg.text)
if current.token_count + seg_tokens > target_tokens and current.segments:
chunks.append(current)
overlap = build_overlap(current.segments, overlap_tokens)
current = Chunk(chunk_index=len(chunks), segments=list(overlap),
token_count=sum(estimate_tokens(s.text) for s in overlap))
current.segments.append(seg)
current.token_count += seg_tokens
if seg.speaker_role not in current.speakers:
current.speakers.append(seg.speaker_role)
if current.segments:
chunks.append(current)
return chunks
The outbound record consumed by podium-rag-context-bridge carries transcript_id, call_id, location_uid, detected_language, language_confidence, redaction_count, and chunks[] with per-chunk speakers and segments.
| Code | Source | Root Cause | Action |
|---|---|---|---|
401 on webhook | Handler | Signature verification failed | Reject; verifier config wrong |
5xx to Podium | Inbox write failed | SQLite unwritable | Return 5xx so Podium retries; page on-call |
ERR_TXP_001 | Reconciler | partial after completed | Ignored by design; logged |
ERR_TXP_002 | Reconciler | No transcript within N hours | Fallback poller fetches directly |
ERR_TXP_003 | Language detector | Transcript < 20 chars | Route to review queue |
ERR_TXP_004 | Redactor | Presidio unavailable | Regex-only mode active; warn per transcript |
ERR_TXP_005 | Queue write | Redis/SQS error | Increment attempts; exponential backoff |
ERR_TXP_006 | Chunker | Single segment > target_tokens | Allow oversize chunk; warn |
ERR_TXP_007 | Processor | attempts > 12 | Move to dead-letter; page on-call |
uvicorn podium_call_transcript_pipeline.webhook_ingest:app --host 0.0.0.0 --port 8080 &
python3 scripts/transcript_chunker.py --process-loop --interval 5
sqlite3 podium_transcripts.db "SELECT transcript_id, event_type, processed_at FROM inbox ORDER BY received_at DESC LIMIT 10;"
python3 scripts/pii_redact.py --input transcript-raw.json --output transcript-redacted.json --audit-log redactions.jsonl
python3 scripts/transcript_poller.py --since-hours 12 --max-age-hours 4 --location-uid "{location-uid}"
python3 scripts/transcript_chunker.py --input transcript-redacted.json --output chunks.json --target-tokens 1500 --overlap-tokens 200
completed over partial and never the inversepodium-rag-context-bridgeERR_TXP_* codes with cause + solution4plugins reuse this skill
First indexed Jul 18, 2026
npx claudepluginhub camillanapoles/claude-code-plugins-plus-skills --plugin podium-packIngest Podium call transcripts with PII redaction, language detection, and idempotent dedup — durable layer between Podium's webhook and a downstream RAG/LLM queue.
Implements audio data handling best practices for Deepgram, including PII redaction, secure storage, retention policies, and GDPR/HIPAA compliance.
Handles AssemblyAI webhook callbacks for transcription completion. Covers submitting transcripts with webhook URLs, verifying auth headers, and fetching results.