diff --git a/.gitignore b/.gitignore index 2ac64e5..ccaa94d 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ __pycache__/ *.pyo .venv/ *.egg-info/ +.ipynb_checkpoints/ diff --git a/packages/core/src/controller.ts b/packages/core/src/controller.ts index 793e7f4..abc4c13 100644 --- a/packages/core/src/controller.ts +++ b/packages/core/src/controller.ts @@ -146,9 +146,13 @@ export function docFromBlocks(blocks: Array<{ type?: string } & UttSpec>): Node return docFromUtterances(blocks) } -function _ensureUttTier(store: AnnotationStore) { - return store.allTiers().find(t => t.isUttTier) - ?? store.addTier('utterance', { isUttTier: true }) +function _ensureUttTier(store: AnnotationStore, participant: string, tierId?: string | null) { + if (tierId) { + const tier = store.getTier(tierId) + if (tier?.isUttTier) return tier + } + return store.allTiers().find(t => t.isUttTier && t.participant === participant) + ?? store.addTier(participant, { isUttTier: true, participant }) } /** @@ -211,7 +215,7 @@ export function createUttSyncPlugin(store: AnnotationStore, yjsSyncKey?: PluginK const existing = store.getAnnotation(id) if (!existing) { - const tier = _ensureUttTier(store) + const tier = _ensureUttTier(store, node.attrs.participant as string, node.attrs.tierId as string | null) store.addAnnotation('', anchors, { tierId: tier.id }, id) } else { const oldNode = oldUtts.get(id) diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 31080a5..a74d0bf 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -9,16 +9,20 @@ const utteranceNode: NodeSpec = { attrs: { id: { default: null }, tier: { default: '' }, + tierId: { default: null }, participant: { default: '' }, startTimeSeconds: { default: null }, endTimeSeconds: { default: null }, + continuationOfId: { default: null }, }, toDOM(node) { return ['p', { class: 'utt', 'data-id': node.attrs.id, 'data-tier': node.attrs.tier, + ...(node.attrs.tierId ? { 'data-tier-id': node.attrs.tierId } : {}), 'data-participant': node.attrs.participant, + ...(node.attrs.continuationOfId ? { 'data-continuation-of': node.attrs.continuationOfId } : {}), }, 0] }, parseDOM: [{ @@ -28,7 +32,9 @@ const utteranceNode: NodeSpec = { return { id: el.getAttribute('data-id'), tier: el.getAttribute('data-tier') ?? '', + tierId: el.getAttribute('data-tier-id') ?? null, participant: el.getAttribute('data-participant') ?? '', + continuationOfId: el.getAttribute('data-continuation-of') ?? null, } }, }], @@ -313,16 +319,51 @@ const commentNode: NodeSpec = { }], } -const utteranceWithOverlap = { ...utteranceNode, content: '(text | overlap_bracket | inline_ann)*' } +const anchorNode: NodeSpec = { + inline: true, + atom: true, + selectable: true, + group: 'inline', + attrs: { + id: {}, + delimiter: {}, + kind: {}, // 'start' | 'end' + }, + toDOM(node) { + const kind = node.attrs.kind as string + return ['span', { + class: `anchor-node anchor-node--${kind}`, + 'data-anchor-id': node.attrs.id, + 'data-anchor-delimiter': node.attrs.delimiter, + 'data-anchor-kind': kind, + contenteditable: 'false', + title: `anchor ${kind}: ${node.attrs.id as string}`, + }, node.attrs.delimiter as string] + }, + parseDOM: [{ + tag: 'span.anchor-node', + getAttrs(dom) { + const el = dom as Element + return { + id: el.getAttribute('data-anchor-id') ?? '', + delimiter: el.getAttribute('data-anchor-delimiter') ?? '*', + kind: el.getAttribute('data-anchor-kind') ?? 'start', + } + }, + }], +} + +const utteranceWithInlines = { ...utteranceNode, content: '(text | overlap_bracket | anchor | inline_ann)*' } export const schema = new Schema({ nodes: { doc: { content: '(utterance | visualization | comment)+' }, text: { group: 'inline' }, - utterance: utteranceWithOverlap, + utterance: utteranceWithInlines, visualization: visualizationNode, comment: commentNode, overlap_bracket: overlapBracketNode, + anchor: anchorNode, image: imageNode, inline_ann: inlineAnnNode, }, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 312daac..8b845fc 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -107,7 +107,8 @@ export interface SlotSchema { label?: string variadic?: boolean style?: import('./types.js').SlotTextStyle - anchorKind: 'span' | 'utterance' | 'pattern' | 'any' + anchorKind: 'textlet' | 'utterance' | 'tier' | 'pattern' | 'any' + tierId?: ID required?: boolean metrics: MetricSchema[] } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ceb17cb..9bc5020 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -46,6 +46,8 @@ export interface TierDefJSON { inlineGloss?: boolean /** If set, tier lane renders as a track visualization backed by this track. */ trackRef?: { trackSetId: ID; trackId: ID } + /** If true, this tier mirrors PM utterance/block nodes for one participant. */ + isUttTier?: boolean } export interface LinguisticTypeJSON { @@ -99,7 +101,8 @@ export interface SlotSchemaJSON { id: ID name: string label?: string - anchorKind: 'span' | 'utterance' | 'pattern' | 'any' + anchorKind: 'textlet' | 'utterance' | 'tier' | 'pattern' | 'any' + tierId?: ID required?: boolean variadic?: boolean style?: SlotTextStyle diff --git a/packages/editor/src/TranscriptEditor.svelte b/packages/editor/src/TranscriptEditor.svelte index 9c5360d..804d008 100644 --- a/packages/editor/src/TranscriptEditor.svelte +++ b/packages/editor/src/TranscriptEditor.svelte @@ -36,9 +36,11 @@ import { refreshAllTimeViews, getCurrentDecimals } from './format.js' import { buildOverlapPlugin, buildOverlapAlignmentPlugin } from './plugins/overlap.js' import { buildSelectionSpacerPlugin, setSelectionMarkSpacers as _setSelectionMarkSpacers } from './plugins/selection-spacer.js' + import { setUttTiersVisible } from './utt-tier.js' import { UtteranceNodeView } from './nodeviews/UtteranceNodeView.js' import { ImageNodeView } from './nodeviews/ImageNodeView.js' import { VisualizationNodeView } from './nodeviews/VisualizationNodeView.js' + import { buildAnchorPlugin, buildAnchorAlignmentPlugin } from './plugins/anchor.js' import { buildImageInputRulePlugin } from './plugins/image-command.js' import { buildSpectInputRulePlugin } from './commands/viz-commands.js' import { buildSymbolInputRulePlugin } from './plugins/symbol-input.js' @@ -47,7 +49,7 @@ import { buildVizSyncPlugin } from './plugins/viz-sync-plugin.js' import { gapCursor } from 'prosemirror-gapcursor' import type { TokenRecord } from '@mumo/core' - import { buildPlayingPlugin, updatePlaying, buildLoopPlugin, updateLoopIds } from './plugins/playback-plugins.js' + import { buildPlayingPlugin, updatePlaying, buildLoopPlugin, updateLoopIds, buildContinuationHeadPlugin, buildContinuationHoverPlugin } from './plugins/playback-plugins.js' import type { FormattingState } from './format.js' interface Props { @@ -87,6 +89,8 @@ showLeftGuide?: boolean showSepGuide?: boolean showRightGuide?: boolean + showTierColumn?: boolean + onTierColW?: (w: string) => void } const { @@ -126,6 +130,8 @@ showLeftGuide = true, showSepGuide = true, showRightGuide = true, + showTierColumn = false, + onTierColW, }: Props = $props() let container: HTMLDivElement @@ -278,7 +284,19 @@ function _applyPlayingClasses(added: string[], removed: string[]) { if (!view) return - updatePlaying(view, added, removed) + // Also mark continuation blocks whose head is active/inactive + const expandedAdded = [...added] + const expandedRemoved = [...removed] + const addedSet = new Set(added) + const removedSet = new Set(removed) + view.state.doc.forEach((node) => { + const headId = node.attrs.continuationOfId as string | null + if (!headId) return + const id = node.attrs.id as string + if (addedSet.has(headId)) expandedAdded.push(id) + if (removedSet.has(headId)) expandedRemoved.push(id) + }) + updatePlaying(view, expandedAdded, expandedRemoved) } export function setLoopIds(ids: string[]): void { @@ -303,27 +321,51 @@ let lineCount = 0 let longestParticipant = 'XX' + let longestTier = '' doc.forEach(n => { if (n.type.name !== 'utterance') return lineCount++ const p = (n.attrs.participant as string) || '' if (p.length > longestParticipant.length) longestParticipant = p + const t = (n.attrs.tier as string) || '' + if (t.length > longestTier.length) longestTier = t }) // Line number width const digits = String(Math.max(lineCount, 1)).length - const lnW = digits <= 2 ? '2rem' : digits === 3 ? '2.5rem' : digits === 4 ? '3rem' : '3.5rem' - el.style.setProperty('--ln-w', lnW) + const lnRem = digits <= 2 ? 2 : digits === 3 ? 2.5 : digits === 4 ? 3 : 3.5 + el.style.setProperty('--ln-w', lnRem.toFixed(2) + 'rem') // Time width — measure a sample formatted time at the element's actual font const timeSample = `00:00:00${decimals > 0 ? '.' + '0'.repeat(decimals) : ''}` const timePx = _measureW(timeSample, 'font-size:0.72em;font-variant-numeric:tabular-nums', el) - el.style.setProperty('--time-w', ((timePx / rem) + 0.6).toFixed(2) + 'rem') + const timeRem = (timePx / rem) + 0.6 + el.style.setProperty('--time-w', timeRem.toFixed(2) + 'rem') // Participant width — measure the longest participant name at the element's actual font const participantPx = _measureW(longestParticipant + ':', 'font-size:0.85em;font-weight:600', el) const participantRem = Math.min(Math.max(participantPx / rem + 0.6, 2), 12) el.style.setProperty('--participant-w', participantRem.toFixed(2) + 'rem') + + // Tier column width — only when showTierColumn is true + let tierColRem = 0 + if (showTierColumn) { + const ref = longestTier.length > 'utterance'.length ? longestTier : 'utterance' + const tierPx = _measureW(ref, 'font-size:0.7em', el) + tierColRem = Math.min(Math.max(tierPx / rem + 0.4, 1.5), 8) + } + const tierColW = tierColRem.toFixed(2) + 'rem' + el.style.setProperty('--tier-col-w', tierColW) + onTierColW?.(tierColW) + + // Compute --utt-meta-w: offset from left edge to content start. + // Layout: ln-w (+ ln-margins 0.25+0.25) + [time-w + gap]* + [tier-col-w + gap]? + participant-w + gap + // Pattern: ln-w + (nCols + 1) * 0.5rem + sum(column widths) + // where nCols = nTimes + (tierCol > 0 ? 1 : 0) + 1 (participant) + const nTimes = showTimes ? (showStart ? 1 : 0) + (showEnd ? 1 : 0) : 0 + const nCols = nTimes + (tierColRem > 0 ? 1 : 0) + 1 + const uttMetaRem = lnRem + (nCols + 1) * 0.5 + nTimes * timeRem + tierColRem + participantRem + el.style.setProperty('--utt-meta-w', uttMetaRem.toFixed(2) + 'rem') } function _activeUtteranceId(sel: Selection): string | null { @@ -358,8 +400,12 @@ buildSlotStylePlugin(), buildPlayingPlugin(), buildLoopPlugin(), + buildContinuationHeadPlugin(), + buildContinuationHoverPlugin(), buildOverlapPlugin(), buildOverlapAlignmentPlugin(onOverlapChange), + buildAnchorPlugin(), + buildAnchorAlignmentPlugin(), buildSelectionSpacerPlugin(), buildImageInputRulePlugin(), buildSpectInputRulePlugin(), @@ -585,6 +631,13 @@ // Clear the accumulator whenever suggest mode is turned off so state doesn't leak. $effect(() => { if (!suggestMode) _suggestAccum = null }) + // Re-run column measurement when visibility props change (showTimes, showStart, showEnd, showTierColumn). + // Also syncs tier column display state with setUttTiersVisible. + $effect(() => { + setUttTiersVisible(showTierColumn) + if (view) _updateColumnWidths(view.state.doc) + }) + export function getView(): EditorView | undefined { return view } @@ -1078,7 +1131,8 @@ --ln-w: 2rem; --ln-margin: 0.25rem; --ln-gutter: calc(var(--ln-w) + var(--ln-margin) * 2); - --utt-meta-w: calc(var(--ln-w) + var(--time-w, 6.5rem) + var(--time-w, 6.5rem) + var(--participant-w, 4rem) + 4 * 0.5rem); + --tier-col-w: 0rem; + --utt-meta-w: calc(var(--ln-w) + var(--time-w, 6.5rem) + var(--time-w, 6.5rem) + var(--tier-col-w, 0rem) + var(--participant-w, 4rem) + 5 * 0.5rem); /* Both times visible: gutter + gap + time + gap + time + half-gap */ --sep-x: calc(var(--ln-gutter) + var(--time-w, 6.5rem) * 2 + 1.25rem); /* Per-guide positions — set to -1px to hide an individual line */ @@ -1130,11 +1184,9 @@ counter-increment: utt-line; min-height: 1.5em; } - .hide-times :global(.comment-row) { padding-left: calc(var(--ln-w, 2rem) + var(--participant-w, 4rem) + 2 * 0.5rem); } - .hide-start :global(.comment-row) { padding-left: calc(var(--ln-w, 2rem) + var(--time-w, 6.5rem) + var(--participant-w, 4rem) + 3 * 0.5rem); } - .hide-end :global(.comment-row) { padding-left: calc(var(--ln-w, 2rem) + var(--time-w, 6.5rem) + var(--participant-w, 4rem) + 3 * 0.5rem); } :global(.utt-row) { + position: relative; display: flex; flex-wrap: wrap; align-items: baseline; @@ -1145,6 +1197,39 @@ counter-increment: utt-line; } + :global(.utt-row.continuation-chain-hover) { + background: rgba(74, 158, 255, 0.12); + box-shadow: 0 1px 0 0 #4a9eff, 0 -1px 0 0 #4a9eff; + } + + :global(.continuation-tooltip) { + z-index: 9999; + background: var(--color-surface-2, #2a2a2a); + color: var(--color-text, #eee); + font-size: 0.75rem; + padding: 2px 7px; + border-radius: 4px; + white-space: nowrap; + pointer-events: none; + } + + :global(.utt-row[data-has-continuation] .utt-content::after) { + content: '\2060↩'; + display: inline-block; + color: var(--color-text-muted, #bbb); + margin-left: 0.35em; + user-select: none; + pointer-events: none; + vertical-align: baseline; + } + + :global(.utt-continuation-mark) { + transform: scaleY(-1); + color: var(--color-text-muted, #bbb); + font-weight: 400; + cursor: default; + } + :global(.utt-linenum) { flex-shrink: 0; width: var(--ln-w, 2rem); @@ -1215,10 +1300,13 @@ font-size: 0.85em; } - :global(.utt-participant::after) { - content: ':'; + :global(.utt-participant-sep) { + display: inline-block; + width: 1em; + text-align: center; color: var(--color-text-light, #888); font-weight: 400; + user-select: none; } :global(.utt-participant[contenteditable="true"]) { @@ -1298,7 +1386,8 @@ :global(.utt-tier) { flex-shrink: 0; - max-width: var(--tier-w, 7rem); + width: var(--tier-col-w, 0rem); + text-align: right; font-size: 0.7em; color: var(--color-text-muted, #aaa); font-variant-numeric: tabular-nums; @@ -1307,21 +1396,17 @@ text-overflow: ellipsis; cursor: text; user-select: none; - border: 1px solid transparent; border-radius: 2px; - padding: 0 0.2em; } :global(.utt-tier:not(:empty):hover) { - border-color: var(--color-border, #ddd); + box-shadow: 0 0 0 1px var(--color-border, #ddd); color: var(--color-text-2, #666); } :global(.utt-tier[contenteditable="true"]) { background: #fffbe6; outline: 1px solid #f0c040; - border-color: transparent; - border-radius: 2px; cursor: text; user-select: text; overflow: visible; @@ -1366,10 +1451,6 @@ border-radius: 2px; } - /* Adjust gloss indent when time columns are hidden */ - .hide-times :global(.utt-gloss) { padding-left: calc(var(--ln-w, 2rem) + var(--participant-w, 4rem) + 2 * 0.5rem); } - .hide-start :global(.utt-gloss), - .hide-end :global(.utt-gloss) { padding-left: calc(var(--ln-w, 2rem) + var(--time-w, 6.5rem) + var(--participant-w, 4rem) + 3 * 0.5rem); } :global(.tok-ws) { white-space: pre-wrap; @@ -1497,6 +1578,16 @@ pointer-events: none; } + :global(.anchor-node) { + display: inline-block; + font-family: monospace; + font-size: 0.85em; + font-weight: 600; + color: var(--color-anchor, #c0752a); + user-select: none; + cursor: default; + } + :global(.img-node) { position: relative; display: inline-block; diff --git a/packages/editor/src/commands/keymaps.ts b/packages/editor/src/commands/keymaps.ts index 4b03076..4e179a4 100644 --- a/packages/editor/src/commands/keymaps.ts +++ b/packages/editor/src/commands/keymaps.ts @@ -31,7 +31,6 @@ const focusParticipant: Command = (state, _dispatch, view) => { function splitBlock( state: Parameters[0], dispatch: Parameters[1], - keepParticipant: boolean, tokenStore: TokenStore, getTokenTime?: (id: string) => { start: number; end: number } | undefined, ): boolean { @@ -61,19 +60,17 @@ function splitBlock( if (atStartOfUtt) { if (parentNode.content.size === 0) { const insertAt = uttPos + parentNode.nodeSize - const p = keepParticipant ? ((parentNode.attrs.participant as string | null) ?? '') : '' - const newAttrs = { id: newId(), participant: p || null, tier: keepParticipant ? (parentNode.attrs.tier ?? '') : 'utterance', startTimeSeconds: null, endTimeSeconds: null } + const newAttrs = { id: newId(), participant: null, tier: 'utterance', startTimeSeconds: null, endTimeSeconds: null } let tr = state.tr.insert(insertAt, parentType.create(newAttrs)) tr = tr.setSelection(TextSelection.create(tr.doc, insertAt + 1)) dispatch(tr.scrollIntoView()) return true } - const newUttParticipant = keepParticipant ? ((parentNode.attrs.participant as string | null) ?? '') : '' const newNode = schema.nodes['utterance'].create({ id: newId(), - participant: newUttParticipant || null, - tier: keepParticipant ? (parentNode.attrs.tier ?? '') : 'utterance', + participant: null, + tier: 'utterance', startTimeSeconds: null, endTimeSeconds: null, }) @@ -109,11 +106,10 @@ function splitBlock( : null const newUttId = newId() - const endUttParticipant = keepParticipant ? ((parentNode.attrs.participant as string | null) ?? '') : '' const newNode = schema.nodes['utterance'].create({ id: newUttId, - participant: endUttParticipant || null, - tier: keepParticipant ? (parentNode.attrs.tier ?? '') : 'utterance', + participant: null, + tier: 'utterance', startTimeSeconds: newStart, endTimeSeconds: newEnd, }) @@ -177,6 +173,54 @@ function splitBlock( return true } +/** Shift+Enter in an utterance: split at cursor, tail becomes a continuation block. */ +const createContinuation: Command = (state, dispatch) => { + const { $from } = state.selection + const uttType = schema.nodes['utterance'] + let uttDepth = -1 + for (let d = $from.depth; d >= 0; d--) { + if ($from.node(d).type === uttType) { uttDepth = d; break } + } + if (uttDepth === -1) return false + + const parentNode = $from.node(uttDepth) + const uttPos = $from.before(uttDepth) + const headId = (parentNode.attrs.continuationOfId as string | null) ?? (parentNode.attrs.id as string) + const splitPos = $from.pos + const cursorOffset = splitPos - $from.start(uttDepth) + + const contId = newId() + const contAttrs = { + id: contId, + participant: parentNode.attrs.participant, + tier: parentNode.attrs.tier, + tierId: parentNode.attrs.tierId ?? null, + continuationOfId: headId, + startTimeSeconds: null, + endTimeSeconds: null, + } + + if (dispatch) { + if (cursorOffset === 0) { + // At start of utterance: insert an empty continuation after the current block + // (splitting here would empty the head and move all content to the continuation) + const insertAt = uttPos + parentNode.nodeSize + let tr = state.tr.insert(insertAt, uttType.create(contAttrs)) + tr = tr.setSelection(TextSelection.create(tr.doc, insertAt + 1)) + dispatch(tr.scrollIntoView()) + } else { + // Mid or end: split text at cursor; tail gets continuationOfId + let tr = state.tr.split(splitPos, 1, [{ type: uttType, attrs: contAttrs }]) + let tailPos: number | null = null + tr.doc.forEach((node, offset) => { if (node.attrs.id === contId) tailPos = offset + 1 }) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (tailPos !== null) tr = tr.setSelection(TextSelection.create(tr.doc, tailPos)) + dispatch(tr.scrollIntoView()) + } + } + return true +} + /** Insert a comment block after the current block. */ const insertCommentBlock: Command = (state, dispatch) => { const { $from } = state.selection @@ -418,8 +462,7 @@ export function buildKeymapPlugin( getTokenTime?: (id: string) => { start: number; end: number } | undefined, onEscapeKey?: () => void, ) { - const splitUtterance: Command = (state, dispatch) => splitBlock(state, dispatch, false, tokenStore, getTokenTime) - const splitUtteranceSameParticipant: Command = (state, dispatch) => splitBlock(state, dispatch, true, tokenStore, getTokenTime) + const splitUtterance: Command = (state, dispatch) => splitBlock(state, dispatch, tokenStore, getTokenTime) return [ keymap({ @@ -431,7 +474,7 @@ export function buildKeymapPlugin( 'Mod-Shift-s': toggleMark(schema.marks['strike']), 'Mod-u': toggleMark(schema.marks['underline']), 'Enter': chainCommands(exitComment, splitUtterance), - 'Shift-Enter': chainCommands(splitComment, splitUtteranceSameParticipant), + 'Shift-Enter': chainCommands(splitComment, createContinuation), 'Alt-/': insertCommentBlock, 'Alt-Shift-Enter': insertVisualization, 'Shift-Tab': focusParticipant, diff --git a/packages/editor/src/nodeviews/UtteranceNodeView.ts b/packages/editor/src/nodeviews/UtteranceNodeView.ts index ab618ee..3e23070 100644 --- a/packages/editor/src/nodeviews/UtteranceNodeView.ts +++ b/packages/editor/src/nodeviews/UtteranceNodeView.ts @@ -35,6 +35,7 @@ export class UtteranceNodeView implements NodeView { _decimals: number _editingWhichTime: 'start' | 'end' | null = null private _participantEditing = false + private _sepEl: HTMLSpanElement private _glossOnSave: ((text: string) => void) | null = null private _hasGlossAnnotation = false @@ -58,6 +59,8 @@ export class UtteranceNodeView implements NodeView { this.lineNumEl.className = 'utt-linenum' this.lineNumEl.contentEditable = 'false' + if (node.attrs.continuationOfId) this.dom.setAttribute('data-continuation', 'true') + this.lineNumRightEl = document.createElement('span') this.lineNumRightEl.className = 'utt-linenum utt-linenum-right' this.lineNumRightEl.contentEditable = 'false' @@ -65,12 +68,12 @@ export class UtteranceNodeView implements NodeView { this.startTimeEl = document.createElement('span') this.startTimeEl.className = 'utt-time utt-time-start' this.startTimeEl.contentEditable = 'false' - this.startTimeEl.textContent = formatTime(node.attrs.startTimeSeconds) + this.startTimeEl.textContent = formatTime(this._displayTime(node, 'start')) this.endTimeEl = document.createElement('span') this.endTimeEl.className = 'utt-time utt-time-end' this.endTimeEl.contentEditable = 'false' - this.endTimeEl.textContent = formatTime(node.attrs.endTimeSeconds) + this.endTimeEl.textContent = formatTime(this._displayTime(node, 'end')) this.participantEl = document.createElement('span') this.participantEl.className = 'utt-participant' @@ -78,6 +81,12 @@ export class UtteranceNodeView implements NodeView { this.participantEl.textContent = node.attrs.participant || '—' this.participantEl.title = 'Click to edit participant (Shift+Tab)' + this._sepEl = document.createElement('span') + this._sepEl.className = 'utt-participant-sep' + this._sepEl.contentEditable = 'false' + this._refreshSepEl(node.attrs.continuationOfId as string | null) + this.participantEl.appendChild(this._sepEl) + this.tierEl = document.createElement('span') this.tierEl.className = 'utt-tier' this.tierEl.contentEditable = 'false' @@ -96,8 +105,8 @@ export class UtteranceNodeView implements NodeView { this.dom.appendChild(this.lineNumEl) this.dom.appendChild(this.startTimeEl) this.dom.appendChild(this.endTimeEl) - this.dom.appendChild(this.participantEl) this.dom.appendChild(this.tierEl) + this.dom.appendChild(this.participantEl) this.dom.appendChild(this.contentDOM) this.dom.appendChild(this.lineNumRightEl) this.dom.appendChild(this.glossEl) @@ -107,13 +116,13 @@ export class UtteranceNodeView implements NodeView { registerUttTierView(node.attrs.id as string, this) this.startTimeEl.addEventListener('click', () => { - const t = this.node.attrs.startTimeSeconds + const t = this._displayTime(this.node, 'start') if (t !== null) this.onSeek?.(t) else this._startTimeEdit('start') }) this.endTimeEl.addEventListener('click', () => { - const t = this.node.attrs.endTimeSeconds + const t = this._displayTime(this.node, 'end') if (t !== null) this.onSeek?.(t) else this._startTimeEdit('end') }) @@ -154,6 +163,16 @@ export class UtteranceNodeView implements NodeView { }) } + private _refreshSepEl(continuationOfId: string | null): void { + if (continuationOfId) { + this._sepEl.className = 'utt-participant-sep utt-continuation-mark' + this._sepEl.textContent = '↪' + } else { + this._sepEl.className = 'utt-participant-sep' + this._sepEl.textContent = ':' + } + } + // Gloss editing applyGloss(entry: GlossEntry): void { @@ -228,16 +247,19 @@ export class UtteranceNodeView implements NodeView { if (this._participantEditing) return this._participantEditing = true const original = this.node.attrs.participant as string + this._sepEl.remove() startFieldEdit(this.participantEl, this.view, (rawText, returnFocus) => { this._participantEditing = false const newParticipant = rawText === '—' ? '' : rawText this.participantEl.textContent = newParticipant || '—' + this.participantEl.appendChild(this._sepEl) const pos = this.getPos() if (pos === undefined) return if (newParticipant !== original) { if (this.participantConflicts(newParticipant, pos)) { this.participantEl.textContent = original || '—' + this.participantEl.appendChild(this._sepEl) this.participantEl.classList.add('utt-participant--conflict') setTimeout(() => { this.participantEl.classList.remove('utt-participant--conflict') }, 700) return @@ -259,6 +281,7 @@ export class UtteranceNodeView implements NodeView { () => { this._participantEditing = false this.participantEl.textContent = original || '—' + this.participantEl.appendChild(this._sepEl) }, ) // Select all for easy replacement @@ -394,6 +417,7 @@ export class UtteranceNodeView implements NodeView { stopEvent(event: Event): boolean { return ( event.target === this.participantEl || + event.target === this._sepEl || event.target === this.tierEl || event.target === this.startTimeEl || event.target === this.endTimeEl || @@ -438,18 +462,36 @@ export class UtteranceNodeView implements NodeView { } if (this.participantEl.contentEditable !== 'true') { this.participantEl.textContent = node.attrs.participant || '—' + this.participantEl.appendChild(this._sepEl) } + this._refreshSepEl(node.attrs.continuationOfId as string | null) if (this.tierEl.contentEditable !== 'true') { this.tierEl.textContent = node.attrs.tier || '' } if (this._editingWhichTime !== 'start') - this.startTimeEl.textContent = formatTime(node.attrs.startTimeSeconds, this._decimals) + this.startTimeEl.textContent = formatTime(this._displayTime(node, 'start'), this._decimals) if (this._editingWhichTime !== 'end') - this.endTimeEl.textContent = formatTime(node.attrs.endTimeSeconds, this._decimals) + this.endTimeEl.textContent = formatTime(this._displayTime(node, 'end'), this._decimals) this.dom.setAttribute('data-id', node.attrs.id) + if (node.attrs.continuationOfId) { + this.dom.setAttribute('data-continuation', 'true') + } else { + this.dom.removeAttribute('data-continuation') + } return true } + private _displayTime(node: Node, which: 'start' | 'end'): number | null { + const headId = node.attrs.continuationOfId as string | null + if (!headId) return which === 'start' ? (node.attrs.startTimeSeconds as number | null) : (node.attrs.endTimeSeconds as number | null) + let result: number | null = null + this.view.state.doc.forEach(block => { + if (block.attrs.id === headId) + result = which === 'start' ? block.attrs.startTimeSeconds : block.attrs.endTimeSeconds + }) + return result + } + private _startTimeEdit(which: 'start' | 'end'): void { startTimeEdit(this, which) } diff --git a/packages/editor/src/plugins/anchor.ts b/packages/editor/src/plugins/anchor.ts new file mode 100644 index 0000000..dc75487 --- /dev/null +++ b/packages/editor/src/plugins/anchor.ts @@ -0,0 +1,186 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument */ +import { Plugin, PluginKey } from 'prosemirror-state' +import { Decoration, DecorationSet } from 'prosemirror-view' +import type { EditorView } from 'prosemirror-view' +import { schema } from '@mumo/core' + +function shortId(): string { + return Math.random().toString(36).slice(2, 6) +} + +// Input plugin +// Trigger: [ or ] after /{delim}{optional_id}* +// /*a*[ → delimiter *, id "a", kind start +// /**[ → delimiter *, auto-id, kind start +// /+b*] → delimiter +, id "b", kind end +// +// Pattern: /\/([*+#%&])(\w*)\*$/ looked up before the [ or ] +// Does NOT conflict with overlap brackets: their lookback /\/(\w*)$/ +// fails when the preceding char is * (not a word char). + +export function buildAnchorPlugin() { + return new Plugin({ + props: { + handleTextInput(view, from, to, text) { + if (from !== to) return false + if (text !== '[' && text !== '|' && text !== ']') return false + + const searchFrom = Math.max(0, from - 40) + const textBefore = view.state.doc.textBetween(searchFrom, from) + const match = textBefore.match(/\/([*+#%&])(\w*)\*$/) + if (!match) return false + + const anchorType = schema.nodes['anchor'] + if (!anchorType) return false + + const delimiter = match[1]! + const givenId = match[2] || '' + const seqLen = match[0].length + const seqStart = from - seqLen + + const id = givenId || shortId() + const kind = text === '[' ? 'start' : text === '|' ? 'middle' : 'end' + const node = anchorType.create({ id, delimiter, kind }) + view.dispatch(view.state.tr.delete(seqStart, from).insert(seqStart, node)) + return true + }, + }, + }) +} + +// Alignment plugin +// Inserts invisible spacer widgets before each anchor atom so that anchors +// with the same id and kind align horizontally across utterances. +// Mirrors the overlap alignment approach: groups are processed in doc order, +// accumulated spacers from earlier groups in the same utterance are accounted +// for, and recalc runs after DOM layout (not inside apply). + +export const anchorAlignmentKey = new PluginKey>('anchorAlignment') + +interface AnchorMeasure { + pos: number + blockStart: number + x: number // viewport-relative left edge of the atom, at time of measurement +} + +// Sum of spacers already committed that precede `pos` within the same block. +function _spacersBefore(spacerMap: Map, pos: number, blockStart: number): number { + let total = 0 + for (const [p, w] of spacerMap) { + if (p >= blockStart && p < pos) total += w + } + return total +} + +function _recalcAnchorAlignment(view: EditorView): void { + const anchorType = schema.nodes['anchor'] + if (!anchorType) return + + // Group by id, separated by kind (starts align with starts, ends with ends). + const startGroups = new Map() + const endGroups = new Map() + const orderedIds = new Set() // doc-order of first occurrence per id + + view.state.doc.descendants((node, pos) => { + if (node.type !== anchorType) return + const id = node.attrs.id as string + const kind = node.attrs.kind as 'start' | 'end' + const $pos = view.state.doc.resolve(pos) + const blockStart = $pos.start($pos.depth) + + // coordsAtPos(pos) = cursor just before the atom = its left edge in viewport + let x: number + try { x = view.coordsAtPos(pos).left } catch { return } + + const entry: AnchorMeasure = { pos, blockStart, x } + const map = kind === 'start' ? startGroups : endGroups + const arr = map.get(id) ?? [] + arr.push(entry) + map.set(id, arr) + orderedIds.add(id) + }) + + const spacerMap = new Map() + + // Process each id in doc order: align starts first, then ends. + // spacersBefore() accounts for spacers already placed earlier in the same + // utterance, since those shift all subsequent inline content rightward. + for (const id of orderedIds) { + const startArr = startGroups.get(id) + if (startArr && startArr.length >= 2) { + const effective = startArr.map(b => b.x + _spacersBefore(spacerMap, b.pos, b.blockStart)) + const maxX = Math.max(...effective) + for (let i = 0; i < startArr.length; i++) { + const delta = maxX - effective[i]! + if (delta > 0.5) { + const prev = spacerMap.get(startArr[i]!.pos) ?? 0 + if (delta > prev) spacerMap.set(startArr[i]!.pos, delta) + } + } + } + + const endArr = endGroups.get(id) + if (endArr && endArr.length >= 2) { + // Include any just-placed start spacers in effective end positions + const effective = endArr.map(b => + b.x + _spacersBefore(spacerMap, b.pos, b.blockStart) + (spacerMap.get(b.pos) ?? 0) + ) + const maxX = Math.max(...effective) + for (let i = 0; i < endArr.length; i++) { + const delta = maxX - effective[i]! + if (delta > 0.5) { + const prev = spacerMap.get(endArr[i]!.pos) ?? 0 + if (delta > prev) spacerMap.set(endArr[i]!.pos, delta) + } + } + } + } + + view.dispatch(view.state.tr.setMeta(anchorAlignmentKey, spacerMap)) +} + +export function buildAnchorAlignmentPlugin() { + return new Plugin>({ + key: anchorAlignmentKey, + state: { + init: () => new Map(), + apply(tr, prev) { + const meta = tr.getMeta(anchorAlignmentKey) + if (meta !== undefined) return meta as Map + if (!tr.docChanged) return prev + // Remap positions through the transaction; recalc fires from view.update + const next = new Map() + for (const [pos, width] of prev) next.set(tr.mapping.map(pos), width) + return next + }, + }, + props: { + decorations(state) { + const map = anchorAlignmentKey.getState(state) + if (!map || map.size === 0) return DecorationSet.empty + const decos: Decoration[] = [] + for (const [pos, width] of map) { + if (width <= 0.5) continue + const el = document.createElement('span') + el.style.cssText = `display:inline-block;width:${width}px;height:1em;vertical-align:text-bottom` + el.setAttribute('aria-hidden', 'true') + el.contentEditable = 'false' + decos.push(Decoration.widget(pos, el, { side: -1 })) + } + return DecorationSet.create(state.doc, decos) + }, + }, + view(v) { + // Fire once after initial layout + queueMicrotask(() => { _recalcAnchorAlignment(v) }) + return { + update(view, prevState) { + // Only recalc when the doc actually changed, not when we dispatched + // the spacer meta (which doesn't change the doc). + if (view.state.doc !== prevState.doc) _recalcAnchorAlignment(view) + }, + destroy() {}, + } + }, + }) +} diff --git a/packages/editor/src/plugins/playback-plugins.ts b/packages/editor/src/plugins/playback-plugins.ts index 119636f..496b88d 100644 --- a/packages/editor/src/plugins/playback-plugins.ts +++ b/packages/editor/src/plugins/playback-plugins.ts @@ -83,3 +83,151 @@ export function updatePlaying(view: EditorView, added: string[], removed: string for (const id of added) next.add(id) view.dispatch(view.state.tr.setMeta(playingKey, next).setMeta('addToHistory', false)) } + +// Continuation head plugin — decorates head blocks with data-has-continuation + +const continuationHeadKey = new PluginKey('continuationHead') + +export function buildContinuationHeadPlugin(): Plugin { + return new Plugin({ + key: continuationHeadKey, + state: { + init(_, state) { return _buildContinuationHeadDecos(state.doc) }, + apply(tr, prev) { + if (!tr.docChanged) return prev + return _buildContinuationHeadDecos(tr.doc) + }, + }, + props: { + decorations(state) { return continuationHeadKey.getState(state)! }, + }, + }) +} + +function _buildContinuationHeadDecos(doc: Parameters[0]): DecorationSet { + // Collect continuations per head, in doc order (forEach is doc order) + const contsByHead = new Map>() + const headOffsets = new Map() + + doc.forEach((node, offset) => { + const id = node.attrs.id as string | undefined + if (!id) return + const headId = node.attrs.continuationOfId as string | null + if (headId) { + const arr = contsByHead.get(headId) ?? [] + arr.push({ id, offset, size: node.nodeSize }) + contsByHead.set(headId, arr) + } else { + headOffsets.set(id, { offset, size: node.nodeSize }) + } + }) + + if (!contsByHead.size) return DecorationSet.empty + const decos: Decoration[] = [] + + for (const [headId, conts] of contsByHead) { + // Head block always gets the marker + const head = headOffsets.get(headId) + if (head) decos.push(Decoration.node(head.offset, head.offset + head.size, { 'data-has-continuation': 'true' })) + // All continuations except the last also get the marker + for (let i = 0; i < conts.length - 1; i++) { + const c = conts[i]! + decos.push(Decoration.node(c.offset, c.offset + c.size, { 'data-has-continuation': 'true' })) + } + } + + return DecorationSet.create(doc, decos) +} + +// Continuation chain hover plugin — adds .continuation-chain-hover to all chain members on mouseover + +export function buildContinuationHoverPlugin(): Plugin { + return new Plugin({ + view(editorView) { + let _hoveredChain: string[] = [] + + // Tooltip element + const tooltip = document.createElement('div') + tooltip.className = 'continuation-tooltip' + tooltip.textContent = 'continuation' + tooltip.style.cssText = 'position:fixed;display:none;pointer-events:none;' + document.body.appendChild(tooltip) + + function _chainFor(id: string, doc: EditorView['state']['doc']): string[] { + let headId = id + doc.forEach((node) => { + if (node.attrs.id === id) { + const contOf = node.attrs.continuationOfId as string | null + if (contOf) headId = contOf + } + }) + const chain: string[] = [] + doc.forEach((node) => { + const nid = node.attrs.id as string | undefined + if (!nid) return + const contOf = node.attrs.continuationOfId as string | null + if (nid === headId || contOf === headId) chain.push(nid) + }) + return chain + } + + function _setChain(ids: string[], pane: HTMLElement) { + for (const id of _hoveredChain) { + pane.querySelector(`[data-id="${CSS.escape(id)}"]`)?.classList.remove('continuation-chain-hover') + } + _hoveredChain = ids + for (const id of ids) { + pane.querySelector(`[data-id="${CSS.escape(id)}"]`)?.classList.add('continuation-chain-hover') + } + } + + function _isOverMark(target: HTMLElement): boolean { + return !!target.closest('.utt-continuation-mark') + } + + function _positionTooltipAbove(mark: HTMLElement) { + const rect = mark.getBoundingClientRect() + tooltip.style.left = `${rect.left + rect.width / 2}px` + tooltip.style.top = `${rect.top - 4}px` + tooltip.style.transform = 'translate(-50%, -100%)' + } + + function onMouseOver(e: MouseEvent) { + const target = e.target as HTMLElement + const row = target.closest('.utt-row[data-continuation], .utt-row[data-has-continuation]') + if (!row) { + _setChain([], editorView.dom) + tooltip.style.display = 'none' + return + } + const id = row.getAttribute('data-id') + if (id) _setChain(_chainFor(id, editorView.state.doc), editorView.dom) + const mark = target.closest('.utt-continuation-mark') + if (mark) { + _positionTooltipAbove(mark) + tooltip.style.display = 'block' + } else { + tooltip.style.display = 'none' + } + } + + function onMouseOut(e: MouseEvent) { + const to = e.relatedTarget as HTMLElement | null + if (to?.closest('.ProseMirror')) return + _setChain([], editorView.dom) + tooltip.style.display = 'none' + } + + editorView.dom.addEventListener('mouseover', onMouseOver) + editorView.dom.addEventListener('mouseout', onMouseOut) + + return { + destroy() { + editorView.dom.removeEventListener('mouseover', onMouseOver) + editorView.dom.removeEventListener('mouseout', onMouseOut) + tooltip.remove() + }, + } + }, + }) +} diff --git a/packages/mumo/src/App.svelte b/packages/mumo/src/App.svelte index 547bffd..bf26202 100644 --- a/packages/mumo/src/App.svelte +++ b/packages/mumo/src/App.svelte @@ -126,6 +126,11 @@ store.loadJSON(_initEmbedDoc) } } + // Origin for automatic derived-state writes (participant promotion, post-undo + // suggestion cleanup). Deliberately NOT in trackedOrigins so these transactions + // never create undo steps or clear the redo stack. + const DERIVED_ORIGIN = Symbol('derived') + const undoManager = new Y.UndoManager( [yXmlFragment, ...store.getYTypes()], { trackedOrigins: new Set([ySyncPluginKey, USER_ORIGIN]) }, @@ -421,6 +426,7 @@ let showEndTime = $state(false) let showGlosses = $state(false) let showUttTierNames = $state(false) + let _tierColAutoShown = false // tracks whether we've auto-shown due to multiple block tiers let suggestMode = $state(false) let showGuides = $state(false) let showLeftGuide = $state(false) @@ -634,10 +640,24 @@ } participantInUse = inUse - for (const [label, meta] of toAdd) store.addParticipant(label, meta) + // Untracked origin: promotion is derived state. With USER_ORIGIN it becomes its own + // undo step, and worse — after undoing a participant's creation this microtask + // immediately re-adds it, clearing the redo stack and trapping undo in a loop. + if (toAdd.length > 0) { + ydoc.transact(() => { + for (const [label, meta] of toAdd) store.addParticipant(label, meta) + }, DERIVED_ORIGIN) + } } let _timelinePushPending = false + let _slotStylesQueued = false + // Declared here (not in the Media section below) because _afterStoreChange's + // microtask reads them via _resolveOverlayTiers/_flushSignals, and that microtask + // can fire at an await point during component init — before later declarations + // are initialized (Svelte 5 TDZ crash). + let mediaSignals = $state([]) + let hiddenSignalIds = $state>(new Set()) function _afterStoreChange() { _recomputeWarnings() @@ -656,6 +676,20 @@ if (_appLoaded && embedConfig?.onChange) embedConfig.onChange(getDoc()) } + function _tryAutoShowTierCol() { + if (_tierColAutoShown) return + const participantBlockCount = new Map() + for (const t of tiers) { + if (!t.isUttTier || !t.participant) continue + participantBlockCount.set(t.participant, (participantBlockCount.get(t.participant) ?? 0) + 1) + } + if ([...participantBlockCount.values()].some(n => n > 1)) { + _tierColAutoShown = true + showUttTierNames = true + setUttTiersVisible(true) + } + } + function syncStore() { tiers = store.allTiersOrdered() annotations = store.allAnnotations() @@ -776,7 +810,7 @@ }) store.on('tier:update', () => { _symbolicCoverage = null; _pushGlosses() }) store.on('tier:remove', () => { _symbolicCoverage = null }) - store.on('tiers:changed', () => { tiers = store.allTiersOrdered(); _afterStoreChange() }) + store.on('tiers:changed', () => { tiers = store.allTiersOrdered(); _afterStoreChange(); _tryAutoShowTierCol() }) store.on('annotations:changed', () => { annotations = store.allAnnotations(); _afterStoreChange() }) store.on('suggestions:changed', _afterStoreChange) @@ -863,12 +897,17 @@ return true }) } - for (const sug of store.allSuggestions()) { - if (sug.change.type === 'pm:replace' && !docSugIds.has(sug.id)) - store.rejectSuggestion(sug.id) - if ((sug.change.type === 'utt:set-time' || sug.change.type === 'utt:set-participant') && !docUttIds.has(sug.change.uttId)) - store.rejectSuggestion(sug.id) - } + // DERIVED_ORIGIN: this runs right after undo/redo — tracked rejections would push + // a fresh undo item and clear the redo stack, breaking redo whenever an undo + // orphans a suggestion. + ydoc.transact(() => { + for (const sug of store.allSuggestions()) { + if (sug.change.type === 'pm:replace' && !docSugIds.has(sug.id)) + store.rejectSuggestion(sug.id) + if ((sug.change.type === 'utt:set-time' || sug.change.type === 'utt:set-participant') && !docUttIds.has(sug.change.uttId)) + store.rejectSuggestion(sug.id) + } + }, DERIVED_ORIGIN) } // When an external pm:replace suggestion arrives, apply its marks to the PM doc. @@ -1185,7 +1224,12 @@ return store.addLinguisticType(constraint, { constraint }).id } - function ensureWordTier(participant: string): TierDef { + function ensureWordTier(participant: string, blockLaneId?: string): TierDef { + if (blockLaneId) { + const tierName = `tokens:${blockLaneId}` + return tiers.find(t => isTokenLtId(t.linguisticTypeId) && t.name === tierName) + ?? store.addTier(tierName, { linguisticTypeId: TOKEN_LT_ID, participant }) + } return store.allTiers().find(t => isTokenLtId(t.linguisticTypeId) && t.participant === participant) ?? store.addTier(`tokens:${participant}`, { linguisticTypeId: TOKEN_LT_ID, participant }) } @@ -1199,8 +1243,7 @@ let mediaState = $state(null) let primaryFrameRate = $state(30) - let mediaSignals = $state([]) - let hiddenSignalIds = $state>(new Set()) + // mediaSignals / hiddenSignalIds are declared above _afterStoreChange (TDZ). let spectrogramSettings = $state({ ...DEFAULT_SPEC_SETTINGS }) let spectrogramProgress = $state<{ done: number; total: number } | null>(null) let specModalOpen = $state(false) @@ -1736,6 +1779,10 @@ let patternSchemaDlgOpen = $state(false) // Called from the annotation:add observer; safe to call recursively (each level // checks for existing children before adding). function _autoPopulateChildTiers(ann: Annotation): void { + // annotation:add also fires when undo/redo restores an annotation. The stack item + // restores whatever children existed; writing here would be captured onto the + // wrong undo/redo stack mid-operation. + if (_isUndoRedo()) return const tierId = ann.features.tierId as string | undefined if (!tierId) return const childTiers = store.allTiers().filter(t => { @@ -1933,7 +1980,7 @@ let patternSchemaDlgOpen = $state(false) closeCtxMenu() } - function ctxEditUttTier() { +function ctxEditUttTier() { const lane = timelineData.lanes.find(l => l.id === ctxMenu.laneId) const currentParticipant = lane?.participant ?? participantFromTierName(ctxMenu.laneId) editUttTierDlg = { open: true, tierName: ctxMenu.laneId, participant: currentParticipant } @@ -1996,7 +2043,8 @@ let patternSchemaDlgOpen = $state(false) // eslint-disable-next-line @typescript-eslint/no-explicit-any const participant: string = (lane as any)?.participant ?? '' if (!participant) { closeCtxMenu(); return } - tierId = ensureWordTier(participant).id + const blockLaneId = ctxMenu.laneId.startsWith('tokens:') ? ctxMenu.laneId.slice('tokens:'.length) : undefined + tierId = ensureWordTier(participant, blockLaneId).id } store.updateTier(tierId, { linguisticTypeId: includedIn ? TOKEN_LT_II_ID : TOKEN_LT_ID }) _pushTimelineData() @@ -2594,21 +2642,30 @@ let patternSchemaDlgOpen = $state(false) function handleSelectBar(nodeId: string | null) { if (slotFillMode && nodeId) { - const { patternId, slotSchemaId, anchorKind } = slotFillMode + const { patternId, slotSchemaId, anchorKind, tierId } = slotFillMode if (store.getAnnotation(nodeId)) { - if (!_slotAccepts(anchorKind, 'span')) { _rejectSlotFill(`This slot expects a ${anchorKind}`); return } + const ann = store.getAnnotation(nodeId)! + const itemKind = ann.features.tierId ? 'tier' : 'textlet' + if (!_slotAccepts(anchorKind, itemKind)) { _rejectSlotFill(`This slot expects a ${anchorKind}`); return } + if (tierId) { + if (ann.features.tierId !== tierId) { + const tierName = store.getTier(tierId)?.name ?? tierId + _rejectSlotFill(`This slot expects an annotation from tier "${tierName}"`) + return + } + } fillSlot(patternId, slotSchemaId, nodeId) return } const kind = docNodeKind(nodeId) if (kind === 'utterance') { if (!_slotAccepts(anchorKind, 'utterance')) { _rejectSlotFill(`This slot expects a ${anchorKind}`); return } - fillSlotWithNewAnnotation(patternId, slotSchemaId, '', [], { utteranceId: nodeId }) + fillSlotWithNewAnnotation(patternId, slotSchemaId, 'utterance', [], { utteranceId: nodeId }) return } if (kind === 'token') { if (!_slotAccepts(anchorKind, 'token')) { _rejectSlotFill(`This slot expects a ${anchorKind}`); return } - fillSlotWithNewAnnotation(patternId, slotSchemaId, '', [], { tokenId: nodeId }) + fillSlotWithNewAnnotation(patternId, slotSchemaId, 'token', [], { tokenId: nodeId }) return } } @@ -2828,9 +2885,16 @@ let patternSchemaDlgOpen = $state(false) .sort((a, b) => a.start - b.start)[0] const clippedEnd = nextBar ? Math.min(end, nextBar.start) : end if (clippedEnd - start < 0.05) return + const _tierBase = laneBase(laneId, uttLane.participant ?? '') + const _blockTier = tiers.find(t => + t.isUttTier && + t.participant === uttLane.participant && + t.name === (_tierBase || (uttLane.participant ?? '')) + ) editorRef?.insertBlockAtTime('utterance', { participant: uttLane.participant, - tier: laneId.startsWith('utterance:') ? '' : laneId, + tier: _tierBase, + ...(_blockTier ? { tierId: _blockTier.id } : {}), startTimeSeconds: +start.toFixed(3), endTimeSeconds: +clippedEnd.toFixed(3), }, start) @@ -3050,6 +3114,16 @@ let patternSchemaDlgOpen = $state(false) // Slot text styling function updateSlotStyles() { + // Always defer: this dispatches a PM transaction, and store observers call it + // mid-Yjs-transaction (undo, redo, remote sync) before ySyncPlugin has updated + // the PM doc. Dispatching then makes y-prosemirror diff the stale PM doc against + // the fragment and write the undone/pre-remote content straight back into Yjs. + if (_slotStylesQueued) return + _slotStylesQueued = true + queueMicrotask(() => { _slotStylesQueued = false; _updateSlotStylesNow() }) + } + + function _updateSlotStylesNow() { const rules: string[] = [] const styledTokens: import('@mumo/editor').StyledTokenRef[] = [] for (const pattern of patterns) { @@ -3175,8 +3249,8 @@ let patternSchemaDlgOpen = $state(false) collab.setLocalState('selectedPatternId', id) } - function handleRequestSlotFill(patternId: ID, slotSchemaId: ID, anchorKind: 'span' | 'utterance' | 'pattern' | 'any') { - slotFillMode = { patternId, slotSchemaId, anchorKind } + function handleRequestSlotFill(patternId: ID, slotSchemaId: ID, anchorKind: 'textlet' | 'utterance' | 'tier' | 'pattern' | 'any', tierId?: ID) { + slotFillMode = { patternId, slotSchemaId, anchorKind, ...(tierId !== undefined ? { tierId } : {}) } // Ensure the pattern that owns this slot is selected if (selectedPatternId !== patternId) { selectedPatternId = patternId @@ -3271,7 +3345,7 @@ let patternSchemaDlgOpen = $state(false) function handleFillWithPattern(refPatternId: ID) { if (!slotFillMode) return const { patternId, slotSchemaId } = slotFillMode - fillSlotWithNewAnnotation(patternId, slotSchemaId, '', [], { patternId: refPatternId }) + fillSlotWithNewAnnotation(patternId, slotSchemaId, 'pattern', [], { patternId: refPatternId }) } let _slotFillError = $state(null) @@ -3283,9 +3357,10 @@ let patternSchemaDlgOpen = $state(false) _slotFillErrorTimer = window.setTimeout(() => { _slotFillError = null }, 2200) } - function _slotAccepts(anchorKind: 'span' | 'utterance' | 'pattern' | 'any', itemKind: 'span' | 'utterance' | 'pattern' | 'token'): boolean { + function _slotAccepts(anchorKind: 'textlet' | 'utterance' | 'tier' | 'pattern' | 'any', itemKind: 'textlet' | 'utterance' | 'pattern' | 'token' | 'tier'): boolean { if (anchorKind === 'any') return true - if (anchorKind === 'span') return itemKind === 'span' || itemKind === 'token' + if (anchorKind === 'textlet') return itemKind === 'textlet' || itemKind === 'token' + if (anchorKind === 'tier') return itemKind === 'tier' return anchorKind === itemKind } @@ -3361,15 +3436,14 @@ let patternSchemaDlgOpen = $state(false) if (!slotFillMode) return const { patternId, slotSchemaId, anchorKind } = slotFillMode if (item.kind === 'annotation') { - if (!_slotAccepts(anchorKind, 'span')) { _rejectSlotFill(`This slot expects a ${anchorKind}`); return } + if (!_slotAccepts(anchorKind, 'textlet')) { _rejectSlotFill(`This slot expects a ${anchorKind}`); return } fillSlot(patternId, slotSchemaId, item.id) } else if (item.kind === 'token') { if (!_slotAccepts(anchorKind, 'token')) { _rejectSlotFill(`This slot expects a ${anchorKind}`); return } - const tok = tokenStore.getToken(item.id) - fillSlotWithNewAnnotation(patternId, slotSchemaId, tok?.text ?? '', [], { tokenId: item.id }) + fillSlotWithNewAnnotation(patternId, slotSchemaId, 'token', [], { tokenId: item.id }) } else if (item.kind === 'utterance') { if (!_slotAccepts(anchorKind, 'utterance')) { _rejectSlotFill(`This slot expects a ${anchorKind}`); return } - fillSlotWithNewAnnotation(patternId, slotSchemaId, '', [], { utteranceId: item.id }) + fillSlotWithNewAnnotation(patternId, slotSchemaId, 'utterance', [], { utteranceId: item.id }) } cancelHoverClose() } @@ -3438,7 +3512,7 @@ let patternSchemaDlgOpen = $state(false) return } const { patternId, slotSchemaId } = slotFillMode - fillSlotWithNewAnnotation(patternId, slotSchemaId, token.text, [], { tokenId: token.id }) + fillSlotWithNewAnnotation(patternId, slotSchemaId, 'token', [], { tokenId: token.id }) } function handleEditorPaneMouseUp(e: MouseEvent) { @@ -3458,7 +3532,7 @@ let patternSchemaDlgOpen = $state(false) _rejectSlotFill(`This slot expects a ${slotFillMode.anchorKind}`) return } - fillSlotWithNewAnnotation(patternId, slotSchemaId, '', [], { utteranceId: row.dataset.id }) + fillSlotWithNewAnnotation(patternId, slotSchemaId, 'utterance', [], { utteranceId: row.dataset.id }) } return } @@ -3471,7 +3545,7 @@ let patternSchemaDlgOpen = $state(false) const liveDoc = editorRef?.liveDoc() ?? currentDoc const rpos = liveDoc.resolve(range.from) const uttId = rpos.depth >= 1 ? (rpos.node(1).attrs['id'] as string | undefined) : undefined - fillSlotWithNewAnnotation(patternId, slotSchemaId, '', [{ type: 'mark', markId }], uttId ? { utteranceId: uttId } : {}) + fillSlotWithNewAnnotation(patternId, slotSchemaId, 'textlet', [{ type: 'mark', markId }], uttId ? { utteranceId: uttId } : {}) } } @@ -3708,7 +3782,15 @@ let patternSchemaDlgOpen = $state(false) return store.addLinguisticType(base, { constraint }).id } - function confirmAddTier(vals: { name: string; participant: string; linguisticTypeId: string; constraint: TierConstraint | ''; inlineGloss: boolean }) { + function confirmAddTier(vals: { name: string; participant: string; linguisticTypeId: string; constraint: TierConstraint | ''; inlineGloss: boolean; isBlockTier: boolean }) { + if (vals.isBlockTier) { + const base = vals.name.trim() + if (!base || !vals.participant) return + store.addTier(base, { isUttTier: true, participant: vals.participant }) + addTierDlg = { ...addTierDlg, open: false } + _pushTimelineData() + return + } const base = vals.name.trim() if (!base || tierNameError(base, vals.participant)) return const fullName = composeTierName(base, vals.participant) @@ -3717,7 +3799,7 @@ let patternSchemaDlgOpen = $state(false) const parentLaneId = addTierDlg.parentLaneId // Resolve the lt-word parent tier (if any) so we can set parentTierId correctly. - // Named word lanes are ann:${ltWordTier.id}; generic word lanes are tokens:${participant}. + // Named word lanes are ann:${ltWordTier.id}; generic word lanes are tokens:${blockLaneId}. // Generic lanes always have a real lt-word tier created on demand via ensureWordTier. let ltTokenParentTier: TierDef | undefined let annParentId: string | undefined @@ -3727,7 +3809,8 @@ let patternSchemaDlgOpen = $state(false) if (!ltTokenParentTier) annParentId = id } else if (parentLaneId.startsWith('tokens:')) { const participant = timelineData.lanes.find(l => l.id === parentLaneId)?.participant ?? '' - if (participant) ltTokenParentTier = ensureWordTier(participant) + const blockLaneId = parentLaneId.slice('tokens:'.length) + if (participant) ltTokenParentTier = ensureWordTier(participant, blockLaneId) } const tokenParticipant = ltTokenParentTier?.participant ?? null @@ -4689,12 +4772,17 @@ let patternSchemaDlgOpen = $state(false) const inPMEditor = !!_target.closest?.('.ProseMirror') const inNativeInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(_target.tagName) - // Undo/redo — not rebindable; always route to PM unless already inside PM or native input - if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'y') && !e.altKey) { + // Undo/redo — not rebindable; always route to PM unless already inside PM or native input. + // e.key is 'Z' when Shift is held, so compare lowercased or Ctrl+Shift+Z never matches. + const _undoKey = e.key.toLowerCase() + if ((e.ctrlKey || e.metaKey) && (_undoKey === 'z' || _undoKey === 'y') && !e.altKey) { if (!inNativeInput && !inPMEditor) { e.preventDefault() - if (e.key === 'z' && !e.shiftKey) undoManager.undo() - else undoManager.redo() + if (_undoKey === 'z' && !e.shiftKey) { + undoManager.undo() + } else { + undoManager.redo() + } } } if (matchKey(e, 'save')) { e.preventDefault(); void (filecontroller.currentFilename ? saveMumo() : saveMumoAs()) } @@ -4751,7 +4839,7 @@ let patternSchemaDlgOpen = $state(false) const slotSchema = schema?.slots[slotIdx] if (slotSchema) { e.preventDefault() - handleRequestSlotFill(selectedPatternId, slotSchema.id, slotSchema.anchorKind) + handleRequestSlotFill(selectedPatternId, slotSchema.id, slotSchema.anchorKind, slotSchema.tierId) } } else if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId) { @@ -5230,10 +5318,11 @@ let patternSchemaDlgOpen = $state(false) onmouseleave={() => setRowHighlight(null)} oncontextmenu={handleEditorContextMenu} > -
+
+
{ editorPaneEl?.style.setProperty('--tier-col-w', w) }} />
-
+
patterns.find(f => f.id === slotFillMode?.patternId)?.schemaId === s.id)} {@const slot_ = schema_?.slots.find(s => s.id === slotFillMode?.slotSchemaId)} + {@const fillTierName_ = slotFillMode.tierId ? (store.getTier(slotFillMode.tierId)?.name ?? slotFillMode.tierId) : null} {#if _slotFillError} {_slotFillError} {:else} Filling {slot_?.label ?? slot_?.name ?? '…'} - ({slotFillMode.anchorKind}) + ({slotFillMode.anchorKind}{fillTierName_ ? ` · ${fillTierName_}` : ''}) — hover token/textlet · click line number for utterance · click timeline bar · Esc to cancel {/if} @@ -6317,6 +6409,8 @@ let patternSchemaDlgOpen = $state(false) } .ech-linenum { flex-shrink: 0; width: 2rem; text-align: right; } .ech-time { flex-shrink: 0; width: 6.5rem; } + .ech-tier { flex-shrink: 0; width: var(--tier-col-w, 3rem); text-align: center; display: none; } + .editor-col-headers.show-tier .ech-tier { display: inline-block; } .ech-participant { flex-shrink: 0; width: 4rem; text-align: right; } .ech-content { flex: 1; display: flex; align-items: center; overflow: visible; padding-left: 5rem; } @@ -6994,6 +7088,10 @@ let patternSchemaDlgOpen = $state(false) cursor: pointer; } + .timeline-pane.slot-fill-active :global(canvas), + .timeline-pane.slot-fill-active :global(.tl-lane-label) { + cursor: crosshair; + } /* ── Slot-fill hover menu ──────────────────────────────────────────────── */ .hover-menu { diff --git a/packages/mumo/src/dialogs/AddTierDlg.svelte b/packages/mumo/src/dialogs/AddTierDlg.svelte index ab50e4b..47989d1 100644 --- a/packages/mumo/src/dialogs/AddTierDlg.svelte +++ b/packages/mumo/src/dialogs/AddTierDlg.svelte @@ -9,7 +9,7 @@ participants?: ParticipantJSON[] /** Returns an error message if (name, participant) can't be used, else null. */ validateName?: (name: string, participant: string) => string | null - onconfirm: (vals: { name: string; participant: string; linguisticTypeId: string; constraint: TierConstraint | ''; inlineGloss: boolean }) => void + onconfirm: (vals: { name: string; participant: string; linguisticTypeId: string; constraint: TierConstraint | ''; inlineGloss: boolean; isBlockTier: boolean }) => void onclose: () => void } = $props() @@ -17,6 +17,7 @@ let participantVal = $state(untrack(() => participant)) let linguisticTypeId = $state('') let isGlossTier = $state(false) + let isBlockTier = $state(false) let constraint = $state(untrack(() => participant) ? 'symbolic_association' : '') const isChild = $derived(!!(participant || parentLaneId)) @@ -29,6 +30,14 @@ ) const nameError = $derived(validateName?.(name, participantVal) ?? null) const finalName = $derived(participantVal && name.trim() && participantVal !== name.trim() ? `${name.trim()}:${participantVal}` : name.trim()) + + const disableAnnotationOptions = $derived(isBlockTier) + const participantRequired = $derived(isBlockTier) + const confirmDisabled = $derived( + !name.trim() || !!nameError || + (isBlockTier && !participantVal) || + (!isBlockTier && (isChild || isGlossTier) && !constraint && !linguisticTypeId) + ) @@ -41,35 +50,45 @@ {#if nameError}

{nameError}

- {:else if finalName && finalName !== name.trim()} + {:else if !isBlockTier && finalName && finalName !== name.trim()}

Will be created as {finalName}

{/if} -
diff --git a/packages/mumo/src/dialogs/PatternSchemaDlg.svelte b/packages/mumo/src/dialogs/PatternSchemaDlg.svelte index 6ae8827..cb7adef 100644 --- a/packages/mumo/src/dialogs/PatternSchemaDlg.svelte +++ b/packages/mumo/src/dialogs/PatternSchemaDlg.svelte @@ -37,7 +37,7 @@ function addSlot() { if (!selected) return patchSlots([...selected.slots, { - id: newId(), name: 'slot', anchorKind: 'span', required: true, metrics: [], + id: newId(), name: 'slot', anchorKind: 'textlet', required: true, metrics: [], }]) } @@ -204,10 +204,11 @@ if (v) patchSlot(slot.id, { label: v }); else { const { label: _, ...rest } = slot; patchSlot(slot.id, rest) } }} />
{slotSchema.label ?? slotSchema.name} + {slotSchema.anchorKind} + {#if slotSchema.tierId} + {@const tierDef_ = store.getTier(slotSchema.tierId)} + {tierDef_?.name ?? slotSchema.tierId} + {/if} {#if i < 9}{i + 1}{/if} {#if !slotSchema.required}opt{/if}
@@ -805,10 +811,15 @@ {#if filling} {:else} - + {/if}
{slotSchema.label ?? slotSchema.name} + {slotSchema.anchorKind} + {#if slotSchema.tierId} + {@const tierDef__ = store.getTier(slotSchema.tierId)} + {tierDef__?.name ?? slotSchema.tierId} + {/if} {#if i < 9}{i + 1}{/if} {#if !slotSchema.required}opt{/if} @@ -1081,9 +1092,21 @@ } .slot-block.filling { background: #fffdf4; border-radius: var(--radius-xs); padding: 0.2rem 0.3rem; margin: 0 -0.3rem; } - .slot-header { display: flex; align-items: center; gap: 0.5rem; flex-wrap: nowrap; } + .slot-header { display: flex; align-items: center; gap: 0.35rem; flex-wrap: nowrap; } .slot-btns { display: flex; gap: 0.2rem; flex-shrink: 0; } .slot-label { font-weight: 500; font-size: var(--font-sm); flex: 1; text-align: right; } + .slot-kind { + font-size: 0.6rem; color: var(--color-text-muted); + border: 1px solid var(--color-border); border-radius: 2px; + padding: 0 0.22rem; line-height: 1.5; flex-shrink: 0; + font-style: italic; + } + .slot-tier-badge { + font-size: 0.6rem; color: var(--color-active-dark); + background: var(--color-active-light); border: 1px solid var(--color-active); + border-radius: 2px; padding: 0 0.22rem; line-height: 1.5; flex-shrink: 0; + max-width: 6rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } .slot-shortcut { font-size: 0.62rem; color: var(--color-text-placeholder); border: 1px solid var(--color-border); border-radius: 2px; diff --git a/packages/mumo/tests/docToTimeline.test.ts b/packages/mumo/tests/docToTimeline.test.ts index 5b39e40..772693f 100644 --- a/packages/mumo/tests/docToTimeline.test.ts +++ b/packages/mumo/tests/docToTimeline.test.ts @@ -53,14 +53,14 @@ describe('utterance bars', () => { const { lanes } = docToTimeline(doc) const ids = lanes.map(l => l.id) expect(ids).toContain('utterance:A') - expect(ids).toContain('tokens:A') + expect(ids).toContain('tokens:utterance:A') }) it('word lane appears after participant lane', () => { const doc = docFromBlocks([{ type: 'utterance', participant: 'A', text: 'x', startTimeSeconds: 0, endTimeSeconds: 1 }]) const { lanes } = docToTimeline(doc) const ids = lanes.map(l => l.id) - expect(ids.indexOf('tokens:A')).toBeGreaterThan(ids.indexOf('utterance:A')) + expect(ids.indexOf('tokens:utterance:A')).toBeGreaterThan(ids.indexOf('utterance:A')) }) }) @@ -75,7 +75,7 @@ describe('word/token bars', () => { it('distributes symbolic (null-time) tokens evenly across the parent span', () => { const doc = docFromBlocks([{ type: 'utterance', participant: 'A', text: 'foo bar', startTimeSeconds: 0, endTimeSeconds: 2 }]) const ts = makeTokenStore(doc) - const wBars = barsIn(docToTimeline(doc, [], [], ts).bars, 'tokens:A') + const wBars = barsIn(docToTimeline(doc, [], [], ts).bars, 'tokens:utterance:A') expect(wBars).toHaveLength(2) // ws excluded expect(wBars[0]!.start).toBeCloseTo(0); expect(wBars[0]!.end).toBeCloseTo(1) expect(wBars[1]!.start).toBeCloseTo(1); expect(wBars[1]!.end).toBeCloseTo(2) @@ -117,7 +117,7 @@ describe('word/token bars', () => { it('whitespace tokens are excluded from word bars', () => { const doc = docFromBlocks([{ type: 'utterance', participant: 'A', text: 'a b c', startTimeSeconds: 0, endTimeSeconds: 3 }]) const ts = makeTokenStore(doc) - const wBars = barsIn(docToTimeline(doc, [], [], ts).bars, 'tokens:A') + const wBars = barsIn(docToTimeline(doc, [], [], ts).bars, 'tokens:utterance:A') expect(wBars).toHaveLength(3) // a, b, c — no ws bars for (const b of wBars) expect(b.label.trim()).not.toBe('') }) diff --git a/packages/serialization/src/mmeaf-emit.ts b/packages/serialization/src/mmeaf-emit.ts index 90f0b66..c4608b6 100644 --- a/packages/serialization/src/mmeaf-emit.ts +++ b/packages/serialization/src/mmeaf-emit.ts @@ -148,6 +148,15 @@ export function emitMMEAF( '@_kind': attrStr(ia['kind'], 'start'), '@_char_offset': String(charOffset), }) + } else if (inline.type === 'anchor') { + const ia = inline.attrs ?? {} + inlineMarkElems.push({ + '@_type': 'anchor', + '@_id': attrStr(ia['id']), + '@_delimiter': attrStr(ia['delimiter']), + '@_kind': attrStr(ia['kind'], 'start'), + '@_char_offset': String(charOffset), + }) } else if (inline.type === 'inline_ann') { const ia = inline.attrs ?? {} const markObj: Record = { @@ -169,8 +178,12 @@ export function emitMMEAF( if (startMs !== undefined) blockObj['@_start_ms'] = startMs if (endMs !== undefined) blockObj['@_end_ms'] = endMs if (annRef) blockObj['@_annotation_ref'] = annRef - const uttTier = (a['tier'] as string | undefined) ?? '' - if (uttTier) blockObj['@_tier'] = uttTier + const uttTier = (a['tier'] as string | undefined) ?? '' + const uttTierId = (a['tierId'] as string | undefined) ?? null + if (uttTier) blockObj['@_tier'] = uttTier + if (uttTierId) blockObj['@_tier_id'] = uttTierId + const contOfId = (a['continuationOfId'] as string | undefined) ?? null + if (contOfId) blockObj['@_continuation_of'] = contOfId if (tokenElems.length > 0) blockObj['mm:t'] = tokenElems if (inlineMarkElems.length > 0) blockObj['mm:inline_mark'] = inlineMarkElems @@ -198,6 +211,15 @@ export function emitMMEAF( '@_kind': attrStr(ia['kind'], 'start'), '@_char_offset': String(charOffset), }) + } else if (inline.type === 'anchor') { + const ia = inline.attrs ?? {} + inlineMarkElems.push({ + '@_type': 'anchor', + '@_id': attrStr(ia['id']), + '@_delimiter': attrStr(ia['delimiter']), + '@_kind': attrStr(ia['kind'], 'start'), + '@_char_offset': String(charOffset), + }) } else if (inline.type === 'inline_ann') { const ia = inline.attrs ?? {} const markObj: Record = { @@ -283,6 +305,7 @@ export function emitMMEAF( '@_name': slot.name, '@_anchor_kind': slot.anchorKind, } + if (slot.tierId) slotObj['@_tier_id'] = slot.tierId if (slot.required) slotObj['@_required'] = 'true' if (slot.variadic) slotObj['@_variadic'] = 'true' if (slot.label) slotObj['@_label'] = slot.label @@ -719,7 +742,7 @@ export function emitMMEAF( // mm:tier_extensions - const tiersWithExt = store.allTiers().filter(t => t.trackRef) + const tiersWithExt = store.allTiers().filter(t => t.trackRef || t.isUttTier) if (tiersWithExt.length > 0) { mumoData['mm:tier_extensions'] = { 'mm:tier_ext': tiersWithExt.map(tier => { @@ -728,6 +751,7 @@ export function emitMMEAF( extObj['@_track_set_id'] = tier.trackRef.trackSetId extObj['@_track_id'] = tier.trackRef.trackId } + if (tier.isUttTier) extObj['@_is_utt_tier'] = 'true' return extObj }), } diff --git a/packages/serialization/src/mmeaf-parse.ts b/packages/serialization/src/mmeaf-parse.ts index 8ac38bd..022bb11 100644 --- a/packages/serialization/src/mmeaf-parse.ts +++ b/packages/serialization/src/mmeaf-parse.ts @@ -398,6 +398,9 @@ export function parseMMEAF(xml: string): MMEAFParseResult { const uttRef = ga(mmEl, 'annotation_ref') if (uttRef) annRefAnchors.set(uttRef, { type: 'utterance', uttId: blockId }) + const contOf = ga(mmEl, 'continuation_of') + if (contOf) block.attrs['continuationOfId'] = contOf + let offset = 0 for (const tok of ((mmEl['mm:t'] ?? []) as Rec[])) { const kind = (ga(tok, 'type') ?? 'word') as TokenRecord['kind'] @@ -432,6 +435,8 @@ export function parseMMEAF(xml: string): MMEAFParseResult { const charOffset = Number(ga(el, 'char_offset') ?? '0') if (type === 'overlap_bracket') { marks.push({ offset: charOffset, node: { type: 'overlap_bracket', attrs: { id: ga(el, 'group_id') ?? '', kind: ga(el, 'kind') ?? 'start' } } }) + } else if (type === 'anchor') { + marks.push({ offset: charOffset, node: { type: 'anchor', attrs: { id: ga(el, 'id') ?? '', delimiter: ga(el, 'delimiter') ?? '*', kind: ga(el, 'kind') ?? 'start' } } }) } else if (type === 'inline_ann') { marks.push({ offset: charOffset, node: { type: 'inline_ann', attrs: { id: ga(el, 'id') ?? '', value: ga(el, 'value') ?? '', vizId: ga(el, 'viz_id') || null } } }) } @@ -497,6 +502,8 @@ export function parseMMEAF(xml: string): MMEAFParseResult { const charOffset = Number(ga(mark, 'char_offset') ?? '0') if (markType === 'overlap_bracket') { inlines.push({ charOffset, node: { type: 'overlap_bracket', attrs: { id: ga(mark, 'group_id') ?? '', kind: ga(mark, 'kind') ?? 'start' } } }) + } else if (markType === 'anchor') { + inlines.push({ charOffset, node: { type: 'anchor', attrs: { id: ga(mark, 'id') ?? '', delimiter: ga(mark, 'delimiter') ?? '*', kind: ga(mark, 'kind') ?? 'start' } } }) } else if (markType === 'inline_ann') { inlines.push({ charOffset, node: { type: 'inline_ann', attrs: { id: ga(mark, 'id') ?? '', value: ga(mark, 'value') ?? '', vizId: ga(mark, 'viz_id') || null } } }) } @@ -620,7 +627,8 @@ export function parseMMEAF(xml: string): MMEAFParseResult { return { id: ga(slotEl, 'id') ?? newId(), name: ga(slotEl, 'name') ?? '', - anchorKind: (ga(slotEl, 'anchor_kind') ?? 'span') as 'span' | 'utterance' | 'pattern' | 'any', + anchorKind: (() => { const raw = ga(slotEl, 'anchor_kind') ?? 'textlet'; return (raw === 'span' ? 'textlet' : raw) as 'textlet' | 'utterance' | 'tier' | 'pattern' | 'any' })(), + ...(ga(slotEl, 'tier_id') ? { tierId: ga(slotEl, 'tier_id')! } : {}), ...(ga(slotEl, 'required') === 'true' ? { required: true } : {}), ...(ga(slotEl, 'variadic') === 'true' ? { variadic: true } : {}), ...(ga(slotEl, 'label') ? { label: ga(slotEl, 'label')! } : {}), @@ -787,6 +795,7 @@ export function parseMMEAF(xml: string): MMEAFParseResult { const trackSetId = ga(el, 'track_set_id') const trackId = ga(el, 'track_id') if (trackSetId && trackId) ext.trackRef = { trackSetId, trackId } + if (ga(el, 'is_utt_tier') === 'true') ext.isUttTier = true if (Object.keys(ext).length > 0) tierExtMap.set(key, ext) } } diff --git a/packages/serialization/tests/mmeaf-roundtrip.test.ts b/packages/serialization/tests/mmeaf-roundtrip.test.ts index 7baf617..5aa51c6 100644 --- a/packages/serialization/tests/mmeaf-roundtrip.test.ts +++ b/packages/serialization/tests/mmeaf-roundtrip.test.ts @@ -266,7 +266,7 @@ describe('pattern schemas round-trip', () => { description: 'Other-initiated self-repair', slots: [ { - id: newId(), name: 'trouble', anchorKind: 'span', required: true, + id: newId(), name: 'trouble', anchorKind: 'textlet', required: true, metrics: [{ id: newId(), name: 'type', type: 'categorical' }], }, { @@ -288,7 +288,7 @@ describe('pattern schemas round-trip', () => { expect(schema.slots).toHaveLength(2) expect(schema.slots[0]!.name).toBe('trouble') expect(schema.slots[0]!.required).toBe(true) - expect(schema.slots[0]!.anchorKind).toBe('span') + expect(schema.slots[0]!.anchorKind).toBe('textlet') expect(schema.slots[0]!.metrics[0]!.type).toBe('categorical') expect(schema.slots[1]!.name).toBe('initiation') }) @@ -311,7 +311,7 @@ describe('pattern schemas round-trip', () => { store.addVocabulary('Types', [], vocabId) store.addPatternSchema({ name: 'Test', slots: [{ - id: newId(), name: 'slot', anchorKind: 'span', metrics: [ + id: newId(), name: 'slot', anchorKind: 'textlet', metrics: [ { id: newId(), name: 'category', type: 'categorical', vocabularyId: vocabId }, ], }], @@ -368,7 +368,7 @@ describe('patterns round-trip', () => { store.addPatternSchema({ name: 'Test', slots: [{ - id: slotId, name: 's', anchorKind: 'span', + id: slotId, name: 's', anchorKind: 'textlet', metrics: [{ id: metricId, name: 'm', type: 'categorical' }], }], }, schemaId) @@ -639,7 +639,7 @@ describe('MMEAF full store round-trip', () => { description: 'Other-initiated self-repair', slots: [ { - id: newId(), name: 'trouble', anchorKind: 'span', required: true, + id: newId(), name: 'trouble', anchorKind: 'textlet', required: true, metrics: [{ id: newId(), name: 'type', type: 'categorical', vocabularyId: vocab.id }], }, { id: newId(), name: 'initiation', anchorKind: 'utterance', metrics: [] }, @@ -734,7 +734,7 @@ describe('annotation_ref round-trip', () => { tokens: [{ kind: 'word', text: 'hello' }], }]) const storedBlockId = (doc.content![0]!.attrs as Record)['id'] as string - const ann = annotationStore.addAnnotation('slot-anchor', [{ type: 'utterance', uttId: storedBlockId }], { utteranceId: storedBlockId }) + const ann = annotationStore.addAnnotation('utterance', [{ type: 'utterance', uttId: storedBlockId }], { utteranceId: storedBlockId }) const xml = emitMMEAF(doc, annotationStore, {}, tokenStore) const result = parseMMEAF(xml) @@ -1136,7 +1136,7 @@ describe('suggestion round-trip', () => { slot: { id: slotId, schemaSlotId, annotationId, metrics: [{ schemaId: 'ms1', value: 'lexical' }] }, pendingAnnotation: { id: pendingId, - type: 'slot-anchor', + type: 'utterance', anchors: [{ type: 'utterance', uttId }], features: { tierId: 'tier1', blockNodeId: uttId }, }, @@ -1157,7 +1157,7 @@ describe('suggestion round-trip', () => { expect(sug.change.slot.metrics[0]).toMatchObject({ schemaId: 'ms1', value: 'lexical' }) const pa = sug.change.pendingAnnotation! expect(pa.id).toBe(pendingId) - expect(pa.type).toBe('slot-anchor') + expect(pa.type).toBe('utterance') expect(pa.anchors).toHaveLength(1) expect(pa.anchors[0]).toMatchObject({ type: 'utterance', uttId }) expect(pa.features['blockNodeId']).toBe(uttId) @@ -1285,7 +1285,7 @@ describe('ID stability round-trip', () => { tokens: [{ kind: 'word', text: 'hello' }], }]) const blockId = (doc.content![0]!.attrs as Record)['id'] as string - annotationStore.addAnnotation('slot-anchor', [{ type: 'utterance', uttId: blockId }], { utteranceId: blockId }) + annotationStore.addAnnotation('utterance', [{ type: 'utterance', uttId: blockId }], { utteranceId: blockId }) const xml = emitMMEAF(doc, annotationStore, {}, tokenStore) const stripped = xml.replace(/[\s\S]*?<\/mm:id_map>/, '') @@ -1293,7 +1293,7 @@ describe('ID stability round-trip', () => { // Fresh block ID minted, but the annotation's refs must be remapped to it const freshBlockId = (result.doc as { content: { attrs: Record }[] }).content[0]!.attrs['id'] as string - const recovered = result.annotations.find(a => a.type === 'slot-anchor') + const recovered = result.annotations.find(a => a.type === 'utterance') expect(recovered).toBeDefined() expect(recovered!.features['utteranceId']).toBe(freshBlockId) }) @@ -1308,7 +1308,7 @@ describe('vocabulary ID stability round-trip', () => { store.addVocabulary('POS tags', [{ id: newId(), value: 'NOUN' }], vocabId) store.addPatternSchema({ name: 'Test', slots: [{ - id: newId(), name: 'slot', anchorKind: 'span', metrics: [ + id: newId(), name: 'slot', anchorKind: 'textlet', metrics: [ { id: newId(), name: 'pos', type: 'categorical', vocabularyId: vocabId }, ], }], diff --git a/packages/serialization/tests/template-apply.test.ts b/packages/serialization/tests/template-apply.test.ts index ecdd074..63592b0 100644 --- a/packages/serialization/tests/template-apply.test.ts +++ b/packages/serialization/tests/template-apply.test.ts @@ -11,7 +11,7 @@ describe('template apply end-to-end', () => { const transcriptStore = makeStore() transcriptStore.addLinguisticType('default-lt') transcriptStore.addLinguisticType('symbolic_association', { constraint: 'symbolic_association' }) - const repairSchema = transcriptStore.addPatternSchema({ name: 'repair', slots: [] }) + const _repairSchema = transcriptStore.addPatternSchema({ name: 'repair', slots: [] }) // Round-trip through MMEAF serialization (same path as the app's MMEAF importer) const emptyDoc = { type: 'doc', content: [] } as never diff --git a/pymumo/mmeaf.mmeaf b/pymumo/mmeaf.mmeaf deleted file mode 100644 index ac55bb4..0000000 --- a/pymumo/mmeaf.mmeaf +++ /dev/null @@ -1,248 +0,0 @@ - - -
- - - - - - - - - - - - - - - - yeah I think the weather has been really strange lately - - - - - I said strange like unusual unpredictable - - - - - right and it affects everything outdoor plans travel - - - - - - - wha- what do you mean by strange - - - - - oh okay yeah I see what you mean - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - lexical - - - referential - - - phonological - - - syntactic - - - - - open-class - - - specific - - - request-for-confirmation - - - - - - yeah - - I - - think - - the - - weather - - has - - been - - really - - strange - - lately - - - wha- - - what - - do - - you - - mean - - by - - strange - - - I - - said - - strange - - like - - unusual - - unpredictable - - - oh - - okay - - yeah - - I - - see - - what - - you - - mean - - - right - - and - - it - - affects - - everything - - outdoor - - plans - - travel - - - - - - - - - - - - - - - - - - - - - - - - - - unusual - - - - - - - - - - - - - the trouble source word - - - - - 1781747022254-1-jg7x0 - - - 1781747022254-2-t4i75 - - - 1781747022254-3-pk99n - - - - - - - - - -
diff --git a/pymumo/pymumo.ipynb b/pymumo/pymumo.ipynb new file mode 100644 index 0000000..3984c8f --- /dev/null +++ b/pymumo/pymumo.ipynb @@ -0,0 +1,1062 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro", + "metadata": {}, + "source": [ + "# pymumo\n", + "Python API for reading `.mmeaf` files produced by mumo." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "install", + "metadata": {}, + "outputs": [], + "source": [ + "# Run once to install in your venv\n", + "# !uv pip install -e .\n", + "# !uv pip install pandas" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "imports", + "metadata": {}, + "outputs": [], + "source": [ + "from mumo import MumoDoc, patterns_df, gaps, pauses, overlaps_by_timing" + ] + }, + { + "cell_type": "markdown", + "id": "section-load", + "metadata": {}, + "source": [ + "## Loading a file" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "load", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "doc = MumoDoc('./tests/fixtures/test.mmeaf')\n", + "doc" + ] + }, + { + "cell_type": "markdown", + "id": "section-utterances", + "metadata": {}, + "source": [ + "## Utterances\n", + "\n", + "`doc.utterances` returns all utterance blocks in transcript order." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "utt-loop", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[SHA] (0.000–1.000s) ugliest so we gotta make c'ncessions hh ˙hhhheh\n", + "[VIV] (1.282–2.282s) n:heyuh\n", + "[MIC] (2.467–3.467s) I do' wan't 'day\n", + "[VIV] (3.645–4.645s) Move a little, can you? \n", + "[MIC] (4.872–5.084s) Mostly taragoose\n", + "[VIV] (5.327–6.327s) °Thanks°\n", + "[MIC] (6.734–7.734s) Bes' girl does'n it?\n", + "[SHA] (9.820–10.820s) hm-hmh yih hmh (.) ˙huhh Best nh hnh Best\n" + ] + } + ], + "source": [ + "for utt in doc.utterances:\n", + " ts = f'{utt.start_time:.3f}–{utt.end_time:.3f}s' if utt.start_time is not None else 'no time'\n", + " print(f'[{utt.participant}] ({ts}) {utt.text}')" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "utt-properties", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "id: 1783311184358-1k111g6m451n\n", + "participant: SHA\n", + "start_time: 0.0\n", + "end_time: 1.0\n", + "text: ugliest so we gotta make c'ncessions hh ˙hhhheh\n" + ] + } + ], + "source": [ + "utt = doc.utterances[0]\n", + "\n", + "print('id: ', utt.id)\n", + "print('participant:', utt.participant)\n", + "print('start_time: ', utt.start_time)\n", + "print('end_time: ', utt.end_time)\n", + "print('text: ', utt.text)" + ] + }, + { + "cell_type": "markdown", + "id": "section-tokens", + "metadata": {}, + "source": [ + "## Tokens\n", + "\n", + "`utt.tokens` — all tokens including whitespace. \n", + "`utt.words` — non-whitespace tokens (words, punctuation, gaps)." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "tokens", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "words: ['ugliest', 'so', 'we', 'gotta', 'make', \"c'ncessions\", 'hh', '˙hhhheh']\n", + "kinds: [('ugliest', 'word'), (' ', 'ws'), ('so', 'word'), (' ', 'ws'), ('we', 'word'), (' ', 'ws'), ('gotta', 'word'), (' ', 'ws'), ('make', 'word'), (' ', 'ws'), (\"c'ncessions\", 'word'), (' ', 'ws'), ('hh', 'word'), (' ', 'ws'), ('˙hhhheh', 'word')]\n" + ] + } + ], + "source": [ + "utt = doc.utterances[0]\n", + "\n", + "print('words:', [t.text for t in utt.words])\n", + "print('kinds:', [(t.text, t.kind) for t in utt.tokens])" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "token-utterance-backref", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Token(word, 'ugliest')\n", + "utterance: Utterance('SHA', \"ugliest so we gotta make c'ncessions hh \")\n" + ] + } + ], + "source": [ + "# Every token links back to its utterance\n", + "tok = utt.words[0]\n", + "print(tok)\n", + "print('utterance:', tok.utterance)" + ] + }, + { + "cell_type": "markdown", + "id": "section-continuations", + "metadata": {}, + "source": [ + "## Continuations\n", + "\n", + "When an utterance is a continuation of another (split-turn / latching), you can navigate the chain." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "continuations", + "metadata": {}, + "outputs": [], + "source": [ + "for utt in doc.utterances:\n", + " if utt.is_continuation:\n", + " print(f'{utt!r} continues {utt.head!r}')\n", + " print(' full chain:', utt.chain)\n", + "\n", + "# Show continuations from the head side\n", + "for utt in doc.utterances:\n", + " if utt.continuations:\n", + " print(f'{utt!r} has continuations: {utt.continuations}')" + ] + }, + { + "cell_type": "markdown", + "id": "section-overlaps", + "metadata": {}, + "source": [ + "## Overlaps\n", + "\n", + "Overlap brackets in CA notation mark where speakers' speech is simultaneous.\n", + "Each `mm:inline_mark type=\"overlap_bracket\"` on an utterance carries a `group_id`\n", + "that ties together the utterances involved.\n", + "\n", + "**Per-utterance marks** tell you where (by character offset) the overlap starts/ends\n", + "in that utterance's text. \n", + "**Overlap groups** aggregate all marks with the same `group_id` across the whole document." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "overlap-marks-per-utt", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[SHA] \"ugliest so we gotta make c'ncessions hh ˙hhhheh\"\n", + " start group='a' offset=16 → 'tta make c'...\n", + " end group='a' offset=32 → 'ions hh ˙h'...\n", + " start group='b' offset=44 → 'heh'...\n", + "[VIV] 'n:heyuh'\n", + " start group='a' offset=0 → 'n:heyuh'...\n", + " end group='a' offset=7 → ''...\n", + "[MIC] \"I do' wan't 'day\"\n", + " start group='b' offset=0 → \"I do' wan'\"...\n", + "[VIV] 'Move a little, can you? '\n", + " start group='c' offset=15 → 'can you? '...\n", + " end group='c' offset=24 → ''...\n", + "[MIC] 'Mostly taragoose'\n", + " start group='c' offset=0 → 'Mostly tar'...\n", + " end group='c' offset=9 → 'ragoose'...\n", + "[MIC] \"Bes' girl does'n it?\"\n", + " start group='d' offset=17 → 'it?'...\n" + ] + } + ], + "source": [ + "# Overlap marks on individual utterances\n", + "for utt in doc.utterances:\n", + " if utt.overlap_marks:\n", + " print(f'[{utt.participant}] {utt.text!r}')\n", + " for mark in utt.overlap_marks:\n", + " snippet = utt.text[mark.char_offset:mark.char_offset + 10]\n", + " print(f' {mark.kind:5} group={mark.group_id!r} offset={mark.char_offset} → {snippet!r}...')" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "overlap-groups", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "OverlapGroup('a', [SHA, VIV])\n", + " [SHA] start@16, end@32 text=\"ugliest so we gotta make c'ncessions hh ˙hhhheh\"\n", + " [VIV] start@0, end@7 text='n:heyuh'\n", + "OverlapGroup('b', [SHA, MIC])\n", + " [SHA] start@44 text=\"ugliest so we gotta make c'ncessions hh ˙hhhheh\"\n", + " [MIC] start@0 text=\"I do' wan't 'day\"\n", + "OverlapGroup('c', [VIV, MIC])\n", + " [VIV] start@15, end@24 text='Move a little, can you? '\n", + " [MIC] start@0, end@9 text='Mostly taragoose'\n", + "OverlapGroup('d', [MIC])\n", + " [MIC] start@17 text=\"Bes' girl does'n it?\"\n" + ] + } + ], + "source": [ + "# All overlap groups in the document\n", + "for og in doc.overlap_groups:\n", + " print(og)\n", + " for utt in og.utterances:\n", + " marks = [m for m in utt.overlap_marks if m.group_id == og.id]\n", + " offsets = ', '.join(f'{m.kind}@{m.char_offset}' for m in marks)\n", + " print(f' [{utt.participant}] {offsets} text={utt.text!r}')" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "overlap-group-timing", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "OverlapGroup('a', [SHA, VIV]) t=1.282–1.000s\n", + "OverlapGroup('b', [SHA, MIC]) t=2.467–1.000s\n", + "OverlapGroup('c', [VIV, MIC]) t=4.872–4.645s\n", + "OverlapGroup('d', [MIC]) t=6.734–7.734s\n" + ] + } + ], + "source": [ + "# Overlap group timing: intersection of the involved utterances' time ranges.\n", + "# start_time > end_time means EAF timestamps don't confirm the bracket overlap\n", + "# (transcriber perception vs. automated alignment).\n", + "for og in doc.overlap_groups:\n", + " print(f'{og} t={og.start_time:.3f}–{og.end_time:.3f}s')" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "overlap-lookup", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "OverlapGroup('a', [SHA, VIV])\n", + "utterances: [Utterance('SHA', \"ugliest so we gotta make c'ncessions hh \"), Utterance('VIV', 'n:heyuh')]\n" + ] + } + ], + "source": [ + "# Look up a single group by id\n", + "og = doc.overlap_group('a')\n", + "print(og)\n", + "print('utterances:', og.utterances)" + ] + }, + { + "cell_type": "markdown", + "id": "section-textlets", + "metadata": {}, + "source": [ + "## Textlets\n", + "\n", + "Textlets are annotated sub-spans of an utterance." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "textlets", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Textlet('°Thanks°')\n", + " text: '°Thanks°'\n", + " utterance: Utterance('VIV', '°Thanks°')\n", + " char span: 0 – 8\n", + " patterns: []\n" + ] + } + ], + "source": [ + "for tl in doc.textlets:\n", + " print(tl)\n", + " print(' text: ', repr(tl.text))\n", + " print(' utterance: ', tl.utterance)\n", + " print(' char span: ', tl.start, '–', tl.end)\n", + " print(' patterns: ', tl.patterns)" + ] + }, + { + "cell_type": "markdown", + "id": "section-patterns", + "metadata": {}, + "source": [ + "## Patterns\n", + "\n", + "Patterns are instances of a pattern schema (e.g. Adjacency Pair, Repair).\n", + "Slots are the filled positions within a pattern." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "patterns-loop", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Pattern('Adjacency Pair', 2 slot(s))\n", + " schema: Adjacency Pair\n", + " note: None\n", + " [fpp] anchor_kind=utterance\n", + " text=\"Bes' girl does'n it?\"\n", + " utterance=Utterance('MIC', \"Bes' girl does'n it?\")\n", + " metric 'FPP type' = 'assessment'\n", + " [spp] anchor_kind=utterance\n", + " text=None\n", + " utterance=None\n", + " metric 'SPP type' = 'acceptance'\n" + ] + } + ], + "source": [ + "for pattern in doc.patterns:\n", + " print(pattern)\n", + " print(' schema:', pattern.schema.name)\n", + " print(' note: ', pattern.note)\n", + " for slot in pattern:\n", + " print(f' [{slot.name}] anchor_kind={slot.anchor_kind}')\n", + " print(f' text={slot.text!r}')\n", + " print(f' utterance={slot.utterance}')\n", + " for mv in slot.metrics:\n", + " print(f' metric {mv.name!r} = {mv.value!r}')" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "pattern-slot-by-name", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "FPP utterance: Utterance('MIC', \"Bes' girl does'n it?\")\n", + "FPP text: Bes' girl does'n it?\n", + "FPP type: assessment\n" + ] + } + ], + "source": [ + "# Access a slot by name\n", + "pattern = doc.patterns[0]\n", + "fpp = pattern['fpp']\n", + "\n", + "print('FPP utterance:', fpp.utterance)\n", + "print('FPP text: ', fpp.text)\n", + "print('FPP type: ', fpp['FPP type']) # metric by name" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "pattern-anchor", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "fpp: UtteranceAnchor(kind='utterance', utterance=Utterance('MIC', \"Bes' girl does'n it?\"))\n", + "spp: None\n" + ] + } + ], + "source": [ + "# The anchor gives you a typed, structured object\n", + "for slot in pattern:\n", + " print(f'{slot.name}: {slot.anchor}')" + ] + }, + { + "cell_type": "markdown", + "id": "section-lookup", + "metadata": {}, + "source": [ + "## Universal lookup\n", + "\n", + "`doc[id]` resolves any ID — utterance, textlet, or pattern." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "lookup", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Utterance('SHA', \"ugliest so we gotta make c'ncessions hh \")\n", + "Textlet('°Thanks°')\n", + "Pattern('Adjacency Pair', 2 slot(s))\n" + ] + } + ], + "source": [ + "utt_id = doc.utterances[0].id\n", + "print(doc[utt_id])\n", + "\n", + "if doc.textlets:\n", + " tl_id = doc.textlets[0].id\n", + " print(doc[tl_id])\n", + "\n", + "if doc.patterns:\n", + " p_id = doc.patterns[0].id\n", + " print(doc[p_id])" + ] + }, + { + "cell_type": "markdown", + "id": "section-schemas", + "metadata": {}, + "source": [ + "## Pattern schemas\n", + "\n", + "Inspect what schemas are defined in the file and what slots they contain." + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "schemas", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Repair (Other-initiated self-repair (Schegloff et al. 1977))\n", + " 'trouble' anchor=textlet (required)\n", + " metric: 'Trouble type' type=categorical\n", + " 'initiation' anchor=textlet (required)\n", + " metric: 'Initiation type' type=categorical\n", + " 'solution' anchor=textlet (optional)\n", + " metric: 'Resolved?' type=boolean\n", + " metric: 'Solution type' type=categorical\n", + "Adjacency Pair (First and second pair parts (Schegloff & Sacks 1973))\n", + " 'fpp' anchor=utterance (required)\n", + " metric: 'FPP type' type=categorical\n", + " 'spp' anchor=utterance (required)\n", + " metric: 'SPP type' type=categorical\n", + " metric: 'Preferred?' type=boolean\n" + ] + } + ], + "source": [ + "for schema in doc.pattern_schemas:\n", + " print(f'{schema.name} ({schema.description})')\n", + " for slot in schema.slots:\n", + " required = '(required)' if slot.required else '(optional)'\n", + " print(f' {slot.name!r:20} anchor={slot.anchor_kind} {required}')\n", + " for m in slot.metrics:\n", + " print(f' metric: {m.name!r} type={m.type}')" + ] + }, + { + "cell_type": "markdown", + "id": "section-eaf", + "metadata": {}, + "source": [ + "## EAF annotations\n", + "\n", + "All standard ELAN tiers and annotations are accessible alongside mumo data." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "eaf-anns", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "['utt:SHA', 'utt:VIV', 'utt:MIC', 'SHA', 'POS']\n" + ] + } + ], + "source": [ + "# Tiers available in the file\n", + "print(list(doc.tiers.keys()))" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "eaf-tier", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "a41: 'ADJ' parent=EafAnnotation('SHA', 'ugliest')\n", + "a42: 'CONJ' parent=EafAnnotation('SHA', 'so')\n", + "a43: 'NOUN' parent=EafAnnotation('SHA', 'we')\n", + "a44: 'VERB' parent=EafAnnotation('SHA', 'gotta')\n", + "a45: 'VERB' parent=EafAnnotation('SHA', 'make')\n", + "a46: 'NOUN' parent=EafAnnotation('SHA', \"c'ncessions\")\n", + "a47: 'BREATH' parent=EafAnnotation('SHA', 'hh')\n", + "a48: 'BREATH' parent=EafAnnotation('SHA', '˙hhhheh')\n", + "a49: '' parent=EafAnnotation('SHA', 'hm-hmh')\n", + "a50: '' parent=EafAnnotation('SHA', 'yih')\n", + "a51: '' parent=EafAnnotation('SHA', 'hmh')\n", + "a52: '' parent=EafAnnotation('SHA', '(.)')\n", + "a53: '' parent=EafAnnotation('SHA', '˙huhh')\n", + "a54: '' parent=EafAnnotation('SHA', 'Best')\n", + "a55: '' parent=EafAnnotation('SHA', 'nh')\n", + "a56: '' parent=EafAnnotation('SHA', 'hnh')\n", + "a57: '' parent=EafAnnotation('SHA', 'Best')\n" + ] + } + ], + "source": [ + "# All annotations on the POS tier\n", + "for ann in doc.eaf_tier('POS'):\n", + " print(f'{ann.id}: {ann.value!r} parent={ann.parent}')" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "eaf-time-query", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "EafAnnotation('utt:SHA', \"ugliest so we gotta make c'ncessions hh ˙hhhheh\") (0.0, 1.0)\n", + "EafAnnotation('utt:VIV', 'n:heyuh') (1.282, 2.282)\n" + ] + } + ], + "source": [ + "# Query by time range (in seconds)\n", + "overlapping = doc.annotations_overlapping(0.0, 2.0)\n", + "for ann in overlapping:\n", + " print(ann, ann.time)" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "utt-eaf-link", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "SHA: eaf_id=a1 value=\"ugliest so we gotta make c'ncessions hh ˙hhhheh\"\n", + "VIV: eaf_id=a3 value='n:heyuh'\n", + "MIC: eaf_id=a6 value=\"I do' wan't 'day\"\n", + "VIV: eaf_id=a4 value='Move a little, can you? '\n", + "MIC: eaf_id=a7 value='Mostly taragoose'\n", + "VIV: eaf_id=a5 value='°Thanks°'\n", + "MIC: eaf_id=a8 value=\"Bes' girl does'n it?\"\n", + "SHA: eaf_id=a2 value='hm-hmh\\xa0yih hmh (.) ˙huhh Best nh hnh Best'\n" + ] + } + ], + "source": [ + "# Each utterance links to its EAF annotation\n", + "for utt in doc.utterances:\n", + " eaf = utt.eaf_annotation\n", + " if eaf:\n", + " print(f'{utt.participant}: eaf_id={eaf.id} value={eaf.value!r}')" + ] + }, + { + "cell_type": "markdown", + "id": "section-gaps", + "metadata": {}, + "source": [ + "## Gaps and pauses\n", + "\n", + "`gaps(doc)` computes the inter-turn interval between every pair of temporally adjacent\n", + "utterances (sorted by start time). Positive = silence; negative = timing overlap.\n", + "\n", + "`pauses(doc, min_duration=0.2)` filters to notable silences only." + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "gaps-all", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Gap('SHA'→'VIV', +0.282s [gap])\n", + "Gap('VIV'→'MIC', +0.185s [gap])\n", + "Gap('MIC'→'VIV', +0.178s [gap])\n", + "Gap('VIV'→'MIC', +0.227s [gap])\n", + "Gap('MIC'→'VIV', +0.243s [gap])\n", + "Gap('VIV'→'MIC', +0.407s [gap])\n", + "Gap('MIC'→'SHA', +2.086s [gap])\n" + ] + } + ], + "source": [ + "# All inter-turn intervals\n", + "for g in gaps(doc):\n", + " print(g)" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "gaps-pauses", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.282s SHA → VIV\n", + " before: \"ugliest so we gotta make c'ncessions hh ˙hhhheh\"\n", + " after: 'n:heyuh'\n", + "0.227s VIV → MIC\n", + " before: 'Move a little, can you? '\n", + " after: 'Mostly taragoose'\n", + "0.243s MIC → VIV\n", + " before: 'Mostly taragoose'\n", + " after: '°Thanks°'\n", + "0.407s VIV → MIC\n", + " before: '°Thanks°'\n", + " after: \"Bes' girl does'n it?\"\n", + "2.086s MIC → SHA\n", + " before: \"Bes' girl does'n it?\"\n", + " after: 'hm-hmh\\xa0yih hmh (.) ˙huhh Best nh hnh Best'\n" + ] + } + ], + "source": [ + "# Only notable silences (>= 0.2 s)\n", + "for g in pauses(doc):\n", + " print(f'{g.duration:.3f}s {g.before.participant} → {g.after.participant}')\n", + " print(f' before: {g.before.text!r}')\n", + " print(f' after: {g.after.text!r}')" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "gaps-filter", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "7 silences, 2 micro-pauses, 1 long pauses, 0 timing overlaps\n" + ] + } + ], + "source": [ + "# Filter by duration range\n", + "from mumo import gaps\n", + "\n", + "silences = gaps(doc, min_duration=0) # gaps only, no overlaps\n", + "micro_pauses = gaps(doc, min_duration=0, max_duration=0.2) # < 0.2 s\n", + "long_pauses = gaps(doc, min_duration=0.5) # >= 0.5 s\n", + "overlaps = overlaps_by_timing(doc) # timing-based overlaps only\n", + "\n", + "print(f'{len(silences)} silences, {len(micro_pauses)} micro-pauses, '\n", + " f'{len(long_pauses)} long pauses, {len(overlaps)} timing overlaps')" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "id": "gaps-fields", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "duration: 0.28200000000000003\n", + "is_overlap: False\n", + "is_silence: True\n", + "before: Utterance('SHA', \"ugliest so we gotta make c'ncessions hh \")\n", + "after: Utterance('VIV', 'n:heyuh')\n" + ] + } + ], + "source": [ + "# Gap objects carry the adjacent utterances and the duration\n", + "g = gaps(doc)[0]\n", + "print('duration: ', g.duration)\n", + "print('is_overlap:', g.is_overlap)\n", + "print('is_silence:', g.is_silence)\n", + "print('before: ', g.before)\n", + "print('after: ', g.after)" + ] + }, + { + "cell_type": "markdown", + "id": "section-dataframes", + "metadata": {}, + "source": [ + "## DataFrames\n", + "\n", + "`patterns_df` produces a wide-format DataFrame — one row per pattern instance,\n", + "with columns for each slot's text, participant, time, and metric values." + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "id": "df-all", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
pattern_idschemanotefppfpp_participantfpp_startfpp_endfpp_FPP typesppspp_participantspp_startspp_endspp_SPP typespp_Preferred?
01783311410290-5m2k683d4s3gAdjacency PairNoneBes' girl does'n it?MIC6.7347.734assessmentNoneNoneNoneNoneacceptanceNone
\n", + "
" + ], + "text/plain": [ + " pattern_id schema note fpp \\\n", + "0 1783311410290-5m2k683d4s3g Adjacency Pair None Bes' girl does'n it? \n", + "\n", + " fpp_participant fpp_start fpp_end fpp_FPP type spp spp_participant \\\n", + "0 MIC 6.734 7.734 assessment None None \n", + "\n", + " spp_start spp_end spp_SPP type spp_Preferred? \n", + "0 None None acceptance None " + ] + }, + "execution_count": 27, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# All patterns across all schemas\n", + "result = patterns_df(doc)\n", + "result # dict of {schema_name: DataFrame} when multiple schemas have instances" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "id": "df-named", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
pattern_idschemanotefppfpp_participantfpp_startfpp_endfpp_FPP typesppspp_participantspp_startspp_endspp_SPP typespp_Preferred?
01783311410290-5m2k683d4s3gAdjacency PairNoneBes' girl does'n it?MIC6.7347.734assessmentNoneNoneNoneNoneacceptanceNone
\n", + "
" + ], + "text/plain": [ + " pattern_id schema note fpp \\\n", + "0 1783311410290-5m2k683d4s3g Adjacency Pair None Bes' girl does'n it? \n", + "\n", + " fpp_participant fpp_start fpp_end fpp_FPP type spp spp_participant \\\n", + "0 MIC 6.734 7.734 assessment None None \n", + "\n", + " spp_start spp_end spp_SPP type spp_Preferred? \n", + "0 None None acceptance None " + ] + }, + "execution_count": 28, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Single schema → DataFrame directly\n", + "df = patterns_df(doc, schema='Adjacency Pair')\n", + "df" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "id": "df-columns", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "['pattern_id', 'schema', 'note', 'fpp', 'fpp_participant', 'fpp_start', 'fpp_end', 'fpp_FPP type', 'spp', 'spp_participant', 'spp_start', 'spp_end', 'spp_SPP type', 'spp_Preferred?']\n" + ] + } + ], + "source": [ + "print(df.columns.tolist())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1eddea64-5934-4470-8313-eb2a276699d8", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "690c912b-fef8-4306-9a9a-ea54d91a62a2", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3f152945-a371-43db-954a-cb4988481e32", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pymumo/src/mumo/__init__.py b/pymumo/src/mumo/__init__.py index 84fba91..319c014 100644 --- a/pymumo/src/mumo/__init__.py +++ b/pymumo/src/mumo/__init__.py @@ -1,17 +1,33 @@ """pymumo - Python library for reading and analysing MMEAF (mumo) files.""" from .doc import MumoDoc -from .views import (UtteranceView, TokenView, AnnotationView, - FrameView, SlotView, MetricValue, TextletView, - UtteranceAnchor, SpanAnchor, TimeAnchor) +from .views import ( + Utterance, Token, Textlet, EafAnnotation, + Pattern, PatternSchema, SlotSchema, MetricSchema, + Slot, MetricValue, + UtteranceAnchor, TextletAnchor, TimeAnchor, + OverlapMark, OverlapGroup, + # backward-compat aliases + PatternView, FrameView, UtteranceView, TokenView, TextletView, + SlotView, SpanAnchor, +) from .sqlite import export_to_sqlite -from .dataframes import frames_df, annotations_df +from .dataframes import patterns_df, frames_df, annotations_df +from .analytics import Gap, gaps, pauses, overlaps_by_timing __all__ = [ 'MumoDoc', - 'UtteranceView', 'TokenView', 'AnnotationView', - 'FrameView', 'SlotView', 'MetricValue', - 'UtteranceAnchor', 'SpanAnchor', 'TimeAnchor', + # core types + 'Utterance', 'Token', 'Textlet', 'EafAnnotation', + 'Pattern', 'PatternSchema', 'SlotSchema', 'MetricSchema', + 'Slot', 'MetricValue', + 'UtteranceAnchor', 'TextletAnchor', 'TimeAnchor', + 'OverlapMark', 'OverlapGroup', + # backward-compat + 'PatternView', 'FrameView', 'UtteranceView', 'TokenView', + 'TextletView', 'SlotView', 'SpanAnchor', + # utilities 'export_to_sqlite', - 'frames_df', 'annotations_df', - 'TextletView', + 'patterns_df', 'frames_df', 'annotations_df', + # analytics + 'Gap', 'gaps', 'pauses', 'overlaps_by_timing', ] diff --git a/pymumo/src/mumo/_parse.py b/pymumo/src/mumo/_parse.py index d92672a..cbce0d2 100644 --- a/pymumo/src/mumo/_parse.py +++ b/pymumo/src/mumo/_parse.py @@ -20,51 +20,66 @@ def _ms_to_sec(val: str | None) -> float | None: def parse_mumo_data(file_path: str) -> dict: - """ - Parse the mm:mumo_data block from an MMEAF file. - - Returns a dict with: - utterances - list of {id, participant, start_ms, end_ms, order} - tokens - list of {id, utt_id, kind, text, start_offset, end_offset} - token_times - {token_id: (start_sec | None, end_sec | None)} - utt_ann_ref - {utt_id: annotation_id} utterance -> annotation back-ref - tok_ann_ref - {token_id: annotation_id} token -> annotation back-ref - marks - {mark_id: {id, block_id, start, end}} - textlets - {textlet_id: {id, mark_id, type, features}} - annotations - {ann_id: {id, type, features}} utterance/token-anchored - frame_schemas - {schema_id: {id, name, description, color, hotkey, slots}} - frames - {frame_id: {id, schema_id, note, slots}} - """ tree = ET.parse(file_path) root = tree.getroot() mm_el = root.find(_q('mumo_data')) if mm_el is None: - return { - 'utterances': [], 'tokens': [], 'token_times': {}, - 'utt_ann_ref': {}, 'tok_ann_ref': {}, - 'marks': {}, 'textlets': {}, 'annotations': {}, - 'frame_schemas': {}, 'frames': {}, - } - - result: dict = { - 'utterances': [], 'tokens': [], 'token_times': {}, - 'utt_ann_ref': {}, 'tok_ann_ref': {}, - 'marks': {}, 'textlets': {}, 'annotations': {}, - 'frame_schemas': {}, 'frames': {}, - } + return _empty() + result = _empty() + _parse_id_map(mm_el, result) _parse_transcript_structure(mm_el, result) + _populate_ann_refs(result) _parse_marks(mm_el, result) _parse_textlets(mm_el, result) _parse_annotations(mm_el, result) - _parse_frame_schemas(mm_el, result) - _parse_frames(mm_el, result) + _parse_pattern_schemas(mm_el, result) + _parse_patterns(mm_el, result) return result +def _empty() -> dict: + return { + 'utterances': [], 'tokens': [], 'token_times': {}, + 'utt_ann_ref': {}, 'tok_ann_ref': {}, + 'overlap_marks': {}, # block_id → list[{group_id, kind, char_offset}] + 'marks': {}, 'textlets': {}, 'annotations': {}, + 'pattern_schemas': {}, 'patterns': {}, + '_id_map': {}, + } + + +# -- id_map ------------------------------------------------------------------ + +def _parse_id_map(mm_el: ET.Element, result: dict) -> None: + """Build mumo-id → EAF-annotation-id reverse map from mm:id_map.""" + id_map_el = mm_el.find(_q('id_map')) + if id_map_el is None: + return + # result['_id_map'] is populated here and consumed when filling utt_ann_ref / + # tok_ann_ref after transcript_structure is parsed. + for entry in id_map_el.findall(_q('id')): + mumo_id = entry.get('id', '') + eaf_id = entry.get('eaf', '') + if mumo_id and eaf_id: + result['_id_map'][mumo_id] = eaf_id + + # -- transcript_structure ----------------------------------------------------- +def _populate_ann_refs(result: dict) -> None: + """Cross-reference id_map entries against parsed utterance/token IDs.""" + id_map = result['_id_map'] + utt_ids = {u['id'] for u in result['utterances']} + tok_ids = {t['id'] for t in result['tokens']} + for mumo_id, eaf_id in id_map.items(): + if mumo_id in utt_ids: + result['utt_ann_ref'][mumo_id] = eaf_id + elif mumo_id in tok_ids: + result['tok_ann_ref'][mumo_id] = eaf_id + + def _parse_transcript_structure(mm_el: ET.Element, result: dict) -> None: ts_el = mm_el.find(_q('transcript_structure')) if ts_el is None: @@ -74,31 +89,41 @@ def _parse_transcript_structure(mm_el: ET.Element, result: dict) -> None: def _parse_utt(utt_el: ET.Element, default_order: int, result: dict) -> None: - block_id = utt_el.get('block_id', '') - participant = utt_el.get('participant', '') - start_ms = utt_el.get('start_ms') - end_ms = utt_el.get('end_ms') - order = int(utt_el.get('order', str(default_order))) - ann_ref = utt_el.get('annotation_ref') + block_id = utt_el.get('block_id', '') + participant = utt_el.get('participant', '') + start_ms = utt_el.get('start_ms') + end_ms = utt_el.get('end_ms') + order = int(utt_el.get('order', str(default_order))) + ann_ref = utt_el.get('annotation_ref') + continuation_of = utt_el.get('continuation_of') result['utterances'].append({ - 'id': block_id, - 'participant': participant, - 'start_ms': int(start_ms) if start_ms is not None else None, - 'end_ms': int(end_ms) if end_ms is not None else None, - 'order': order, + 'id': block_id, + 'participant': participant, + 'start_ms': int(start_ms) if start_ms is not None else None, + 'end_ms': int(end_ms) if end_ms is not None else None, + 'order': order, + 'continuation_of': continuation_of, }) if ann_ref: result['utt_ann_ref'][block_id] = ann_ref + for mark_el in utt_el.findall(_q('inline_mark')): + if mark_el.get('type') == 'overlap_bracket': + result['overlap_marks'].setdefault(block_id, []).append({ + 'group_id': mark_el.get('group_id', ''), + 'kind': mark_el.get('kind', ''), + 'char_offset': int(mark_el.get('char_offset', '0')), + }) + offset = 0 for tok_el in utt_el.findall(_q('t')): - kind = tok_el.get('type', 'word') - tok_id = tok_el.get('id', '') - text = tok_el.text or '' - t_s = tok_el.get('start_ms') - t_e = tok_el.get('end_ms') - tok_ref = tok_el.get('annotation_ref') + kind = tok_el.get('type', 'word') + tok_id = tok_el.get('id', '') + text = tok_el.text or '' + t_s = tok_el.get('start_ms') + t_e = tok_el.get('end_ms') + tok_ref = tok_el.get('annotation_ref') result['tokens'].append({ 'id': tok_id, 'utt_id': block_id, 'kind': kind, 'text': text, @@ -147,7 +172,7 @@ def _parse_textlets(mm_el: ET.Element, result: dict) -> None: } -# -- annotations (utterance/token-anchored) ----------------------------------- +# -- annotations -------------------------------------------------------------- def _parse_annotations(mm_el: ET.Element, result: dict) -> None: anns_el = mm_el.find(_q('annotations')) @@ -165,13 +190,13 @@ def _parse_annotations(mm_el: ET.Element, result: dict) -> None: } -# -- frame_schemas ------------------------------------------------------------ +# -- pattern_schemas ---------------------------------------------------------- -def _parse_frame_schemas(mm_el: ET.Element, result: dict) -> None: - schemas_el = mm_el.find(_q('frame_schemas')) +def _parse_pattern_schemas(mm_el: ET.Element, result: dict) -> None: + schemas_el = mm_el.find(_q('pattern_schemas')) if schemas_el is None: return - for s_el in schemas_el.findall(_q('frame_schema')): + for s_el in schemas_el.findall(_q('pattern_schema')): sid = s_el.get('id', '') slots = [] for slot_el in s_el.findall(_q('slot')): @@ -187,13 +212,13 @@ def _parse_frame_schemas(mm_el: ET.Element, result: dict) -> None: 'id': slot_el.get('id', ''), 'name': slot_el.get('name', ''), 'label': slot_el.get('label'), - 'anchor_kind': slot_el.get('anchor_kind', 'span'), + 'anchor_kind': slot_el.get('anchor_kind', 'textlet'), 'required': slot_el.get('required') == 'true', 'variadic': slot_el.get('variadic') == 'true', 'metrics': metrics, }) color_str = s_el.get('color') - result['frame_schemas'][sid] = { + result['pattern_schemas'][sid] = { 'id': sid, 'name': s_el.get('name', ''), 'description': s_el.get('description'), @@ -203,16 +228,16 @@ def _parse_frame_schemas(mm_el: ET.Element, result: dict) -> None: } -# -- frames ------------------------------------------------------------------- +# -- patterns ----------------------------------------------------------------- -def _parse_frames(mm_el: ET.Element, result: dict) -> None: - frames_el = mm_el.find(_q('frames')) - if frames_el is None: +def _parse_patterns(mm_el: ET.Element, result: dict) -> None: + patterns_el = mm_el.find(_q('patterns')) + if patterns_el is None: return - for f_el in frames_el.findall(_q('frame')): - fid = f_el.get('id', '') + for p_el in patterns_el.findall(_q('pattern')): + pid = p_el.get('id', '') slots = [] - for si_el in f_el.findall(_q('slot_instance')): + for si_el in p_el.findall(_q('slot_instance')): metrics = [] for mv_el in si_el.findall(_q('metric_value')): metrics.append({ @@ -225,9 +250,9 @@ def _parse_frames(mm_el: ET.Element, result: dict) -> None: 'annotation_id': si_el.get('annotation_id', ''), 'metrics': metrics, }) - result['frames'][fid] = { - 'id': fid, - 'schema_id': f_el.get('schema_id', ''), - 'note': f_el.get('note'), + result['patterns'][pid] = { + 'id': pid, + 'schema_id': p_el.get('schema_id', ''), + 'note': p_el.get('note'), 'slots': slots, } diff --git a/pymumo/src/mumo/analytics.py b/pymumo/src/mumo/analytics.py new file mode 100644 index 0000000..fb82632 --- /dev/null +++ b/pymumo/src/mumo/analytics.py @@ -0,0 +1,85 @@ +"""Convenience functions for conversational timing analysis.""" +from __future__ import annotations +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .doc import MumoDoc + from .views import Utterance + + +@dataclass +class Gap: + """ + Inter-turn interval between two temporally consecutive utterances. + + duration > 0: silence between turns + duration < 0: next turn starts while previous is still ongoing (overlap) + duration == 0: back-to-back with no gap + """ + before: object # Utterance + after: object # Utterance + duration: float # seconds + + @property + def is_overlap(self) -> bool: + return self.duration < 0 + + @property + def is_silence(self) -> bool: + return self.duration > 0 + + def __repr__(self) -> str: + label = 'overlap' if self.is_overlap else 'gap' + return (f'Gap({self.before.participant!r}→{self.after.participant!r}, ' + f'{self.duration:+.3f}s [{label}])') + + +def gaps(doc: MumoDoc, *, + min_duration: float | None = None, + max_duration: float | None = None) -> list[Gap]: + """ + All inter-turn intervals between temporally adjacent timed utterances, + sorted by the start time of the later utterance. + + Filters: + min_duration — skip intervals shorter than this (seconds) + max_duration — skip intervals longer than this (seconds) + + Typical use: + gaps(doc) # everything + gaps(doc, min_duration=0) # only silences (no overlaps) + gaps(doc, max_duration=0) # only overlaps + gaps(doc, min_duration=0.2) # notable pauses only + """ + timed = sorted( + [u for u in doc.utterances + if u.start_time is not None and u.end_time is not None], + key=lambda u: u.start_time, + ) + result = [] + for i in range(len(timed) - 1): + a, b = timed[i], timed[i + 1] + dur = b.start_time - a.end_time + if min_duration is not None and dur < min_duration: + continue + if max_duration is not None and dur > max_duration: + continue + result.append(Gap(before=a, after=b, duration=dur)) + return result + + +def pauses(doc: MumoDoc, min_duration: float = 0.2) -> list[Gap]: + """ + Inter-turn silences of at least *min_duration* seconds (default 0.2 s). + Overlaps are excluded. + """ + return gaps(doc, min_duration=min_duration) + + +def overlaps_by_timing(doc: MumoDoc) -> list[Gap]: + """ + Inter-turn intervals where the next turn starts before the previous ends. + Complements doc.overlap_groups (which uses bracket marks, not raw timing). + """ + return gaps(doc, max_duration=0) diff --git a/pymumo/src/mumo/dataframes.py b/pymumo/src/mumo/dataframes.py index 1bb6761..e7ed972 100644 --- a/pymumo/src/mumo/dataframes.py +++ b/pymumo/src/mumo/dataframes.py @@ -15,14 +15,12 @@ def _require_pandas(): raise ImportError('pandas is required: pip install pandas') -# -- frames ------------------------------------------------------------------- - -def frames_df(doc: MumoDoc, schema: str | None = None) -> dict[str, pd.DataFrame] | pd.DataFrame: +def patterns_df(doc: MumoDoc, schema: str | None = None) -> 'dict[str, pd.DataFrame] | pd.DataFrame': """ - Build a wide-format DataFrame from frame instances, one row per frame. + Build a wide-format DataFrame from pattern instances, one row per pattern. - Each slot in the schema becomes a group of columns: - {slot} - text content (span text, or full utterance text) + Each slot becomes a group of columns: + {slot} - text of the anchor {slot}_participant - speaker of the anchored utterance {slot}_start - start time in seconds {slot}_end - end time in seconds @@ -30,101 +28,100 @@ def frames_df(doc: MumoDoc, schema: str | None = None) -> dict[str, pd.DataFrame If *schema* is given, returns a single DataFrame for that schema. Otherwise returns a dict {schema_name: DataFrame} for every schema - that has at least one frame instance. + that has at least one pattern instance. """ pd = _require_pandas() - schemas = doc._mumo['frame_schemas'] - if schema is not None: - target = next((s for s in schemas.values() if s['name'] == schema), None) - if target is None: - raise KeyError(f'No frame schema named {schema!r}') - return _schema_df(doc, target, pd) + ps = doc.pattern_schema(schema) + if ps is None: + raise KeyError(f'No pattern schema named {schema!r}') + return _schema_df(doc, ps, pd) - # all schemas that have instances + schema_ids_with_patterns = {p._rec['schema_id'] for p in doc.patterns} result = {} - schema_ids_with_frames = {f['schema_id'] for f in doc._mumo['frames'].values()} - for s in schemas.values(): - if s['id'] in schema_ids_with_frames: - result[s['name']] = _schema_df(doc, s, pd) + for ps in doc.pattern_schemas: + if ps.id in schema_ids_with_patterns: + result[ps.name] = _schema_df(doc, ps, pd) if len(result) == 1: return next(iter(result.values())) return result -def _schema_df(doc: MumoDoc, schema: dict, pd) -> pd.DataFrame: - from .views import FrameView +frames_df = patterns_df # backward-compat alias + + +def _schema_df(doc: MumoDoc, schema, pd) -> 'pd.DataFrame': + from .views import TextletAnchor, UtteranceAnchor, TimeAnchor rows = [] - for frame in doc._mumo['frames'].values(): - if frame['schema_id'] != schema['id']: + for pattern in doc.patterns: + if pattern._rec['schema_id'] != schema.id: continue - fv = FrameView(frame, doc) - row: dict = {'frame_id': frame['id'], 'schema': schema['name'], 'note': frame.get('note')} + row: dict = {'pattern_id': pattern.id, 'schema': schema.name, 'note': pattern.note} - for slot_def in schema['slots']: - sv = fv.slot(slot_def['name']) - name = slot_def['name'] + for slot_def in schema.slots: + sv = pattern.slot(slot_def.name) + name = slot_def.name if sv is None: - row[name] = None + row[name] = None row[f'{name}_participant'] = None - row[f'{name}_start'] = None - row[f'{name}_end'] = None - for m in slot_def.get('metrics', []): - row[f"{name}_{m['name']}"] = None + row[f'{name}_start'] = None + row[f'{name}_end'] = None + for m in slot_def.metrics: + row[f'{name}_{m.name}'] = None continue anchor = sv.anchor - if anchor is not None: - from .views import SpanAnchor, UtteranceAnchor, TimeAnchor - if isinstance(anchor, (SpanAnchor, UtteranceAnchor)): - utt = anchor.utterance - row[name] = anchor.text if isinstance(anchor, SpanAnchor) else utt.text - row[f'{name}_participant'] = utt.participant - row[f'{name}_start'] = utt.start_time - row[f'{name}_end'] = utt.end_time - elif isinstance(anchor, TimeAnchor): - row[name] = None - row[f'{name}_participant'] = None - row[f'{name}_start'] = anchor.start - row[f'{name}_end'] = anchor.end + if isinstance(anchor, (TextletAnchor, UtteranceAnchor)): + utt = anchor.utterance + row[name] = anchor.text if isinstance(anchor, TextletAnchor) else utt.text + row[f'{name}_participant'] = utt.participant + row[f'{name}_start'] = utt.start_time + row[f'{name}_end'] = utt.end_time + elif isinstance(anchor, TimeAnchor): + row[name] = None + row[f'{name}_participant'] = None + row[f'{name}_start'] = anchor.start + row[f'{name}_end'] = anchor.end else: - row[name] = None + row[name] = None row[f'{name}_participant'] = None - row[f'{name}_start'] = None - row[f'{name}_end'] = None + row[f'{name}_start'] = None + row[f'{name}_end'] = None for mv in sv.metrics: - schema_slot = sv.schema or {} - metric_def = next((m for m in schema_slot.get('metrics', []) - if m['id'] == mv.schema_id), None) - col = f"{name}_{metric_def['name']}" if metric_def else f'{name}_metric_{mv.schema_id[:8]}' + col = f'{name}_{mv.name}' if mv.name else f'{name}_metric_{mv.schema_id[:8]}' row[col] = mv.value - # fill any metric columns that weren't covered - for m in slot_def.get('metrics', []): - col = f"{name}_{m['name']}" + for m in slot_def.metrics: + col = f'{name}_{m.name}' if col not in row: row[col] = None rows.append(row) - return pd.DataFrame(rows) + if not rows: + base_cols = ['pattern_id', 'schema', 'note'] + slot_cols: list[str] = [] + for slot_def in schema.slots: + slot_cols += [slot_def.name, f'{slot_def.name}_participant', + f'{slot_def.name}_start', f'{slot_def.name}_end'] + for m in slot_def.metrics: + slot_cols.append(f'{slot_def.name}_{m.name}') + return pd.DataFrame(columns=base_cols + slot_cols) + return pd.DataFrame(rows) -# -- annotations -------------------------------------------------------------- -def annotations_df(doc: MumoDoc, tier: str | None = None) -> pd.DataFrame: +def annotations_df(doc: MumoDoc, tier: str | None = None) -> 'pd.DataFrame': """ Build a DataFrame with one row per EAF annotation. Columns: id, tier, participant, constraint, value, start_time, end_time, parent_id. - - If *tier* is given, only that tier's annotations are included. """ pd = _require_pandas() diff --git a/pymumo/src/mumo/doc.py b/pymumo/src/mumo/doc.py index 510a08f..98b7334 100644 --- a/pymumo/src/mumo/doc.py +++ b/pymumo/src/mumo/doc.py @@ -1,8 +1,12 @@ -"""MumoDoc: pympi.Elan.Eaf subclass with mumo-specific query API.""" +"""MumoDoc: the main entry point for reading an MMEAF file.""" from __future__ import annotations import pympi from ._parse import parse_mumo_data -from .views import UtteranceView, TokenView, AnnotationView, FrameView, TextletView +from .views import ( + Utterance, Token, Textlet, EafAnnotation, + Pattern, PatternSchema, Slot, + OverlapMark, OverlapGroup, +) class MumoDoc(pympi.Elan.Eaf): @@ -10,21 +14,30 @@ class MumoDoc(pympi.Elan.Eaf): An MMEAF file loaded as a pympi.Elan.Eaf with the mm:mumo_data block parsed on top. - EAF tiers/annotations are accessible via the standard pympi API - (self.tiers, self.annotations, self.timeslots, ...). Mumo utterances, - tokens, and frames are accessible via the query methods below. + EAF tiers/annotations are accessible via the standard pympi API. + Mumo-specific data is accessible via the rich query properties below. """ def __init__(self, file_path: str) -> None: super().__init__(file_path) self._mmeaf_path = file_path - self._mumo = parse_mumo_data(file_path) - self._utt_by_id: dict = {u['id']: u for u in self._mumo['utterances']} - self._token_by_id: dict = {t['id']: t for t in self._mumo['tokens']} - self._tokens_by_utt: dict[str, list[dict]] = {} - for tok in self._mumo['tokens']: + self._elan = self # alias so views can reach pympi internals + self._raw = parse_mumo_data(file_path) + + # indexes + self._utt_by_id: dict[str, dict] = {u['id']: u for u in self._raw['utterances']} + self._tokens_by_utt: dict[str, list[dict]] = {} + self._continuations: dict[str, list[str]] = {} # head_id → [continuation_ids] + + for tok in self._raw['tokens']: self._tokens_by_utt.setdefault(tok['utt_id'], []).append(tok) - # Build time index for aligned EAF annotations: list of (start_ms, end_ms, ann_id) + + for utt in self._raw['utterances']: + head = utt.get('continuation_of') + if head: + self._continuations.setdefault(head, []).append(utt['id']) + + # EAF time index self._aligned_time_entries: list[tuple[int, int, str]] = [] for tier_name, (aligned, _ref, _attrs, _ord) in self.tiers.items(): for ann_id, (bts, ets, _val, _) in aligned.items(): @@ -33,133 +46,171 @@ def __init__(self, file_path: str) -> None: if s is not None and e is not None: self._aligned_time_entries.append((s, e, ann_id)) - # -- Utterance queries ------------------------------------------------- - - def utterance(self, uid: str) -> UtteranceView | None: - u = self._utt_by_id.get(uid) - return UtteranceView(u, self) if u else None - - def all_utterances(self) -> list[UtteranceView]: - utts = sorted(self._mumo['utterances'], key=lambda u: u['order']) - return [UtteranceView(u, self) for u in utts] - - # -- Token queries ----------------------------------------------------- - - def token(self, tid: str) -> TokenView | None: - t = self._token_by_id.get(tid) - return TokenView(t, self) if t else None - - def all_tokens(self) -> list[TokenView]: - return [TokenView(t, self) for t in self._mumo['tokens']] - - # -- ELAN annotation queries ------------------------------------------- - # Named eaf_* to avoid shadowing pympi's self.annotations dict attribute. - - def eaf_annotation(self, ann_id: str) -> AnnotationView | None: - if ann_id in self.annotations: - return AnnotationView(ann_id, self) + # ----------------------------------------------------------------------- + # Primary collections + # ----------------------------------------------------------------------- + + @property + def utterances(self) -> list[Utterance]: + """All utterance blocks, sorted by order.""" + return [Utterance(u, self) + for u in sorted(self._raw['utterances'], key=lambda u: u['order'])] + + @property + def textlets(self) -> list[Textlet]: + return [Textlet(tl, self) for tl in self._raw['textlets'].values()] + + @property + def eaf_annotations(self) -> list[EafAnnotation]: + """All EAF-tier annotations.""" + return [EafAnnotation(aid, self) for aid in self._elan.annotations] + + @property + def patterns(self) -> list[Pattern]: + return [Pattern(p, self) for p in self._raw['patterns'].values()] + + @property + def pattern_schemas(self) -> list[PatternSchema]: + return [PatternSchema(s) for s in self._raw['pattern_schemas'].values()] + + @property + def overlap_groups(self) -> list[OverlapGroup]: + groups: dict[str, list] = {} + for utt in self.utterances: + for mark in utt.overlap_marks: + groups.setdefault(mark.group_id, []).append((utt, mark)) + return [OverlapGroup(gid, entries) for gid, entries in groups.items()] + + def overlap_group(self, group_id: str) -> OverlapGroup | None: + entries = [] + for utt in self.utterances: + for mark in utt.overlap_marks: + if mark.group_id == group_id: + entries.append((utt, mark)) + return OverlapGroup(group_id, entries) if entries else None + + # ----------------------------------------------------------------------- + # Lookup by ID + # ----------------------------------------------------------------------- + + def __getitem__(self, obj_id: str) -> 'Utterance | Textlet | Pattern | EafAnnotation': + if obj_id in self._utt_by_id: + return Utterance(self._utt_by_id[obj_id], self) + if obj_id in self._raw['textlets']: + return Textlet(self._raw['textlets'][obj_id], self) + if obj_id in self._raw['patterns']: + return Pattern(self._raw['patterns'][obj_id], self) + if obj_id in self._elan.annotations: + return EafAnnotation(obj_id, self) + raise KeyError(obj_id) + + def utterance(self, uid: str) -> Utterance | None: + rec = self._utt_by_id.get(uid) + return Utterance(rec, self) if rec else None + + def textlet(self, tid: str) -> Textlet | None: + rec = self._raw['textlets'].get(tid) + return Textlet(rec, self) if rec else None + + def pattern(self, pid: str) -> Pattern | None: + rec = self._raw['patterns'].get(pid) + return Pattern(rec, self) if rec else None + + def pattern_schema(self, name_or_id: str) -> PatternSchema | None: + for rec in self._raw['pattern_schemas'].values(): + if rec['id'] == name_or_id or rec['name'] == name_or_id: + return PatternSchema(rec) return None - def all_eaf_annotations(self) -> list[AnnotationView]: - return [AnnotationView(aid, self) for aid in self.annotations] + # ----------------------------------------------------------------------- + # EAF queries + # ----------------------------------------------------------------------- - def eaf_tier(self, tier_name: str) -> list[AnnotationView]: - """All annotations in a named EAF tier.""" + def eaf_tier(self, tier_name: str) -> list[EafAnnotation]: if tier_name not in self.tiers: return [] aligned, ref, _, _ = self.tiers[tier_name] - return [AnnotationView(aid, self) for aid in (*aligned, *ref)] - - # -- Frame queries ----------------------------------------------------- - - def frame(self, fid: str) -> FrameView | None: - f = self._mumo['frames'].get(fid) - return FrameView(f, self) if f else None - - def all_frames(self) -> list[FrameView]: - return [FrameView(f, self) for f in self._mumo['frames'].values()] - - # -- Textlet queries --------------------------------------------------- - - def textlet(self, tid: str) -> TextletView | None: - tl = self._mumo['textlets'].get(tid) - return TextletView(tl, self) if tl else None - - def all_textlets(self) -> list[TextletView]: - return [TextletView(tl, self) for tl in self._mumo['textlets'].values()] - - def frames_for_textlet(self, textlet_id: str) -> list[FrameView]: - """All frames that have a slot referencing this textlet.""" - result = [] - for frame in self._mumo['frames'].values(): - for slot in frame['slots']: - if slot.get('annotation_id') == textlet_id: - result.append(FrameView(frame, self)) - break - return result - - # -- Cross-reference queries ------------------------------------------- + return [EafAnnotation(aid, self) for aid in (*aligned, *ref)] def annotations_overlapping(self, start_s: float, end_s: float, - tier: str | None = None) -> list[AnnotationView]: - """EAF annotations whose time span overlaps [start_s, end_s] (seconds).""" + tier: str | None = None) -> list[EafAnnotation]: start_ms = start_s * 1000 end_ms = end_s * 1000 result = [] for s, e, ann_id in self._aligned_time_entries: if s >= end_ms or e <= start_ms: continue - if tier is None or self.annotations.get(ann_id) == tier: - result.append(AnnotationView(ann_id, self)) + if tier is None or self._elan.annotations.get(ann_id) == tier: + result.append(EafAnnotation(ann_id, self)) return result - def frames_overlapping(self, start_s: float, end_s: float, - schema: str | None = None) -> list[FrameView]: - """Frames that have at least one slot whose time overlaps [start_s, end_s].""" + def patterns_overlapping(self, start_s: float, end_s: float, + schema: str | None = None) -> list[Pattern]: result = [] - for frame in self._mumo['frames'].values(): + for rec in self._raw['patterns'].values(): if schema is not None: - fs = self._mumo['frame_schemas'].get(frame['schema_id']) - if fs is None or fs['name'] != schema: + ps = self._raw['pattern_schemas'].get(rec['schema_id']) + if ps is None or ps['name'] != schema: continue - fv = FrameView(frame, self) - for sv in fv.slots: + pv = Pattern(rec, self) + for sv in pv.slots: t = sv.time if t and t[0] < end_s and t[1] > start_s: - result.append(fv) + result.append(pv) break return result - # -- Internal helpers -------------------------------------------------- + # ----------------------------------------------------------------------- + # Internal helpers (used by view objects) + # ----------------------------------------------------------------------- - def _utterance_view(self, uid: str) -> UtteranceView | None: - u = self._utt_by_id.get(uid) - return UtteranceView(u, self) if u else None + def _utt_obj(self, uid: str) -> Utterance | None: + rec = self._utt_by_id.get(uid) + return Utterance(rec, self) if rec else None - def _tokens_for_utt(self, uid: str) -> list[dict]: + def _tokens_for(self, uid: str) -> list[dict]: return self._tokens_by_utt.get(uid, []) - def _resolve_ann_time(self, ann_id: str, visited: set) -> tuple[float, float] | None: - """Walk the parent chain to resolve a concrete time span (seconds).""" + def _continuations_of(self, head_id: str) -> list[Utterance]: + return [Utterance(self._utt_by_id[cid], self) + for cid in self._continuations.get(head_id, []) + if cid in self._utt_by_id] + + def _patterns_for_utt(self, utt_id: str) -> list[Pattern]: + result = [] + for rec in self._raw['patterns'].values(): + for slot in rec['slots']: + ann = self._raw['annotations'].get(slot.get('annotation_id', '')) + if ann and ann['features'].get('utteranceId') == utt_id: + result.append(Pattern(rec, self)) + break + return result + + def _patterns_for_textlet(self, textlet_id: str) -> list[Pattern]: + result = [] + for rec in self._raw['patterns'].values(): + for slot in rec['slots']: + if slot.get('annotation_id') == textlet_id: + result.append(Pattern(rec, self)) + break + return result + + def _resolve_eaf_time(self, ann_id: str, visited: set) -> tuple[float, float] | None: if ann_id in visited: return None visited.add(ann_id) - - tier_name = self.annotations.get(ann_id) + tier_name = self._elan.annotations.get(ann_id) if not tier_name: return None - aligned, ref, _, _ = self.tiers[tier_name] if ann_id in aligned: - begin_ts, end_ts, _, _ = aligned[ann_id] - s_ms = self.timeslots.get(begin_ts) - e_ms = self.timeslots.get(end_ts) + bts, ets, _, _ = aligned[ann_id] + s_ms = self.timeslots.get(bts) + e_ms = self.timeslots.get(ets) if s_ms is not None and e_ms is not None: return (s_ms / 1000.0, e_ms / 1000.0) - if ann_id in ref: parent_id = ref[ann_id][0] if parent_id: - return self._resolve_ann_time(parent_id, visited) - + return self._resolve_eaf_time(parent_id, visited) return None diff --git a/pymumo/src/mumo/sqlite.py b/pymumo/src/mumo/sqlite.py index 567b371..efe820e 100644 --- a/pymumo/src/mumo/sqlite.py +++ b/pymumo/src/mumo/sqlite.py @@ -47,7 +47,7 @@ FOREIGN KEY(utterance_id) REFERENCES utterances(id) ); -CREATE TABLE IF NOT EXISTS frame_schemas ( +CREATE TABLE IF NOT EXISTS pattern_schemas ( id TEXT PRIMARY KEY, document_id TEXT NOT NULL, name TEXT, @@ -65,24 +65,24 @@ anchor_kind TEXT, required INTEGER, variadic INTEGER, - FOREIGN KEY(frame_schema_id) REFERENCES frame_schemas(id) + FOREIGN KEY(frame_schema_id) REFERENCES pattern_schemas(id) ); -CREATE TABLE IF NOT EXISTS frames ( +CREATE TABLE IF NOT EXISTS patterns ( id TEXT PRIMARY KEY, document_id TEXT NOT NULL, schema_id TEXT NOT NULL, note TEXT, FOREIGN KEY(document_id) REFERENCES documents(id), - FOREIGN KEY(schema_id) REFERENCES frame_schemas(id) + FOREIGN KEY(schema_id) REFERENCES pattern_schemas(id) ); CREATE TABLE IF NOT EXISTS slot_instances ( id TEXT PRIMARY KEY, - frame_id TEXT NOT NULL, + pattern_id TEXT NOT NULL, schema_slot_id TEXT NOT NULL, annotation_id TEXT, - FOREIGN KEY(frame_id) REFERENCES frames(id) + FOREIGN KEY(pattern_id) REFERENCES patterns(id) ); CREATE TABLE IF NOT EXISTS metric_values ( @@ -144,7 +144,7 @@ def export_to_sqlite( ) # utterances - for utt in doc._mumo['utterances']: + for utt in doc._raw['utterances']: s = utt['start_ms'] / 1000.0 if utt['start_ms'] is not None else None e = utt['end_ms'] / 1000.0 if utt['end_ms'] is not None else None con.execute( @@ -155,9 +155,9 @@ def export_to_sqlite( ) # tokens - token_times = doc._mumo['token_times'] + token_times = doc._raw['token_times'] pos_counter: dict[str, int] = {} - for tok in doc._mumo['tokens']: + for tok in doc._raw['tokens']: uid = tok['utt_id'] pos = pos_counter.get(uid, 0) pos_counter[uid] = pos + 1 @@ -171,9 +171,9 @@ def export_to_sqlite( ) # frame schemas and slot schemas - for schema in doc._mumo['frame_schemas'].values(): + for schema in doc._raw['pattern_schemas'].values(): con.execute( - 'INSERT OR REPLACE INTO frame_schemas ' + 'INSERT OR REPLACE INTO pattern_schemas ' '(id, document_id, name, description, color, hotkey) VALUES (?,?,?,?,?,?)', (schema['id'], doc_id, schema['name'], schema.get('description'), schema.get('color'), schema.get('hotkey')), @@ -190,15 +190,15 @@ def export_to_sqlite( ) # frames, slot instances, metric values - for frame in doc._mumo['frames'].values(): + for frame in doc._raw['patterns'].values(): con.execute( - 'INSERT OR REPLACE INTO frames (id, document_id, schema_id, note) VALUES (?,?,?,?)', + 'INSERT OR REPLACE INTO patterns (id, document_id, schema_id, note) VALUES (?,?,?,?)', (frame['id'], doc_id, frame['schema_id'], frame.get('note')), ) for slot in frame['slots']: con.execute( 'INSERT OR REPLACE INTO slot_instances ' - '(id, frame_id, schema_slot_id, annotation_id) VALUES (?,?,?,?)', + '(id, pattern_id, schema_slot_id, annotation_id) VALUES (?,?,?,?)', (slot['id'], frame['id'], slot['schema_slot_id'], slot.get('annotation_id') or None), ) diff --git a/pymumo/src/mumo/views.py b/pymumo/src/mumo/views.py index cc08984..90e158e 100644 --- a/pymumo/src/mumo/views.py +++ b/pymumo/src/mumo/views.py @@ -1,176 +1,291 @@ -"""View classes wrapping parsed mumo data and pympi ELAN tier data.""" +"""Rich view objects wrapping parsed mumo data.""" from __future__ import annotations -from typing import NamedTuple, TYPE_CHECKING +from typing import Iterator, NamedTuple, TYPE_CHECKING if TYPE_CHECKING: from .doc import MumoDoc +# --------------------------------------------------------------------------- +# Anchor types +# --------------------------------------------------------------------------- + class UtteranceAnchor(NamedTuple): - kind: str # always 'utterance' - utterance: object # UtteranceView + kind: str # 'utterance' + utterance: object + +class TextletAnchor(NamedTuple): + kind: str # 'textlet' + utterance: object + start: int + end: int + text: str +class TimeAnchor(NamedTuple): + kind: str # 'time' + start: float + end: float -class SpanAnchor(NamedTuple): - kind: str # always 'span' - utterance: object # UtteranceView - start: int # char offset (inclusive) - end: int # char offset (exclusive) - text: str +SpanAnchor = TextletAnchor # backward-compat -class TimeAnchor(NamedTuple): - kind: str # always 'time' - start: float # seconds - end: float # seconds +class OverlapMark(NamedTuple): + group_id: str + kind: str # 'start' | 'end' + char_offset: int + + +class OverlapGroup: + """All overlap bracket marks sharing a group_id, across all utterances.""" + __slots__ = ('_id', '_entries') + + def __init__(self, group_id: str, entries: list) -> None: + self._id = group_id + self._entries = entries # list of (Utterance, OverlapMark) + + @property + def id(self) -> str: + return self._id + + @property + def utterances(self) -> list: + seen: set[str] = set() + result = [] + for utt, _ in self._entries: + if utt.id not in seen: + seen.add(utt.id) + result.append(utt) + return result + + @property + def start_time(self) -> float | None: + """Latest start time among involved utterances (when overlap begins).""" + times = [u.start_time for u in self.utterances if u.start_time is not None] + return max(times) if times else None + + @property + def end_time(self) -> float | None: + """Earliest end time among involved utterances (when overlap ends).""" + times = [u.end_time for u in self.utterances if u.end_time is not None] + return min(times) if times else None + + def __repr__(self) -> str: + participants = ', '.join(u.participant for u in self.utterances) + return f'OverlapGroup({self._id!r}, [{participants}])' + + +# --------------------------------------------------------------------------- +# Token +# --------------------------------------------------------------------------- +class Token: + __slots__ = ('_rec', '_doc') -class UtteranceView: - def __init__(self, record: dict, doc: MumoDoc) -> None: - self._record = record + def __init__(self, rec: dict, doc: MumoDoc) -> None: + self._rec = rec self._doc = doc @property def id(self) -> str: - return self._record['id'] + return self._rec['id'] @property - def participant(self) -> str: - return self._record['participant'] + def text(self) -> str: + return self._rec['text'] @property - def order(self) -> int: - return self._record['order'] + def kind(self) -> str: + return self._rec['kind'] + + @property + def utterance(self) -> Utterance: + return self._doc._utt_obj(self._rec['utt_id']) + + @property + def time(self) -> tuple[float | None, float | None] | None: + return self._doc._raw['token_times'].get(self._rec['id']) + + @property + def bounded_time(self) -> tuple[float, float] | None: + utt = self.utterance + utt_s = utt.start_time + utt_e = utt.end_time + stored = self.time + start = stored[0] if stored and stored[0] is not None else utt_s + end = stored[1] if stored and stored[1] is not None else utt_e + if start is None or end is None: + return None + if utt_s is not None: + start = max(start, utt_s) + if utt_e is not None: + end = min(end, utt_e) + return (start, end) + + def __repr__(self) -> str: + return f'Token({self.kind}, {self.text!r})' + + +# --------------------------------------------------------------------------- +# Utterance +# --------------------------------------------------------------------------- + +class Utterance: + __slots__ = ('_rec', '_doc') + + def __init__(self, rec: dict, doc: MumoDoc) -> None: + self._rec = rec + self._doc = doc @property - def start_ms(self) -> int | None: - return self._record['start_ms'] + def id(self) -> str: + return self._rec['id'] @property - def end_ms(self) -> int | None: - return self._record['end_ms'] + def participant(self) -> str: + return self._rec['participant'] + + @property + def order(self) -> int: + return self._rec['order'] @property def start_time(self) -> float | None: - ms = self._record['start_ms'] + ms = self._rec['start_ms'] return ms / 1000.0 if ms is not None else None @property def end_time(self) -> float | None: - ms = self._record['end_ms'] + ms = self._rec['end_ms'] return ms / 1000.0 if ms is not None else None @property - def tokens(self) -> list[TokenView]: - return [TokenView(t, self._doc) for t in self._doc._tokens_for_utt(self._record['id'])] + def overlap_marks(self) -> list[OverlapMark]: + return [OverlapMark(**m) + for m in self._doc._raw['overlap_marks'].get(self.id, [])] + + @property + def tokens(self) -> list[Token]: + return [Token(t, self._doc) for t in self._doc._tokens_for(self.id)] + + @property + def words(self) -> list[Token]: + return [Token(t, self._doc) for t in self._doc._tokens_for(self.id) + if t['kind'] not in ('ws',)] @property def text(self) -> str: - return ''.join(t['text'] for t in self._doc._tokens_for_utt(self._record['id'])) + return ''.join(t['text'] for t in self._doc._tokens_for(self.id)) + + # -- continuations ------------------------------------------------------- + + @property + def is_continuation(self) -> bool: + return self._rec.get('continuation_of') is not None + + @property + def head(self) -> Utterance | None: + """The utterance this continues, or None if this is already the head.""" + cid = self._rec.get('continuation_of') + return self._doc._utt_obj(cid) if cid else None + + @property + def continuations(self) -> list[Utterance]: + """Direct continuations of this utterance (not recursive).""" + return self._doc._continuations_of(self.id) + + @property + def chain(self) -> list[Utterance]: + """The full continuation chain: head + all continuations, in order.""" + head = self.head or self + return [head] + self._doc._continuations_of(head.id) + + # -- cross-refs ---------------------------------------------------------- + + @property + def textlets(self) -> list[Textlet]: + return [tl for tl in self._doc.textlets if tl.utterance.id == self.id] @property - def eaf_annotation(self) -> object | None: - """The EAF annotation directly referencing this utterance (via annotation_ref).""" - ann_id = self._doc._mumo['utt_ann_ref'].get(self._record['id']) - if ann_id and ann_id in self._doc.annotations: - return AnnotationView(ann_id, self._doc) + def patterns(self) -> list[Pattern]: + return self._doc._patterns_for_utt(self.id) + + @property + def eaf_annotation(self) -> EafAnnotation | None: + ann_id = self._doc._raw['utt_ann_ref'].get(self.id) + if ann_id and ann_id in self._doc._elan.annotations: + return EafAnnotation(ann_id, self._doc) return None - def eaf_annotations(self, tier: str | None = None) -> list: - """ - Aligned EAF annotations overlapping this utterance's time span, plus - their ref/symbolic children (POS, gloss, etc.). - - This is the structural path: frame -> slot -> utterance -> annotations. - """ - s, e = self.start_time, self.end_time - if s is None or e is None: - return [] - aligned = self._doc.annotations_overlapping(s, e, tier=tier) - seen = {a.id for a in aligned} - result = list(aligned) - for a in aligned: - for child in a.children: - if child.id not in seen: - seen.add(child.id) - result.append(child) - return result + def __repr__(self) -> str: + cont = ' (continuation)' if self.is_continuation else '' + return f'Utterance({self.participant!r}, {self.text[:40]!r}{cont})' + +# --------------------------------------------------------------------------- +# Textlet +# --------------------------------------------------------------------------- -class TokenView: - def __init__(self, record: dict, doc: MumoDoc) -> None: - self._record = record +class Textlet: + __slots__ = ('_rec', '_doc') + + def __init__(self, rec: dict, doc: MumoDoc) -> None: + self._rec = rec self._doc = doc @property def id(self) -> str: - return self._record['id'] + return self._rec['id'] @property - def text(self) -> str: - return self._record['text'] + def type(self) -> str: + return self._rec.get('type', '') @property - def kind(self) -> str: - return self._record['kind'] + def _mark(self) -> dict | None: + return self._doc._raw['marks'].get(self._rec['mark_id']) @property - def utterance(self) -> UtteranceView | None: - return self._doc._utterance_view(self._record['utt_id']) + def utterance(self) -> Utterance: + mark = self._mark + if mark: + return self._doc._utt_obj(mark['block_id']) + raise ValueError(f'Textlet {self.id} has no mark') @property - def index(self) -> int: - """0-based index among non-ws tokens in the utterance.""" - word_toks = [t for t in self._doc._tokens_for_utt(self._record['utt_id']) - if t['kind'] != 'ws'] - return next((i for i, t in enumerate(word_toks) if t['id'] == self._record['id']), -1) + def start(self) -> int: + mark = self._mark + return mark['start'] if mark else 0 @property - def eaf_annotation(self) -> object | None: - """The EAF annotation directly referencing this token (via annotation_ref).""" - ann_id = self._doc._mumo['tok_ann_ref'].get(self._record['id']) - if ann_id and ann_id in self._doc.annotations: - return AnnotationView(ann_id, self._doc) - return None + def end(self) -> int: + mark = self._mark + return mark['end'] if mark else 0 @property - def tier_annotations(self) -> list: - """This token's direct EAF annotation and its symbolic children (POS, gloss, etc.).""" - ann = self.eaf_annotation - if ann is None: - return [] - return [ann, *ann.children] - - @property - def time(self) -> tuple[float | None, float | None] | None: - """Stored token time as (start_sec, end_sec); either side may be None.""" - return self._doc._mumo['token_times'].get(self._record['id']) + def text(self) -> str: + mark = self._mark + if not mark: + return '' + utt = self._doc._utt_obj(mark['block_id']) + return utt.text[mark['start']:mark['end']] @property - def bounded_time(self) -> tuple[float, float] | None: - """Token time clamped to utterance bounds; open boundaries filled from utterance edges.""" - utt = self.utterance - utt_s = utt.start_time if utt else None - utt_e = utt.end_time if utt else None - stored = self.time + def patterns(self) -> list[Pattern]: + return self._doc._patterns_for_textlet(self.id) - start = stored[0] if stored is not None and stored[0] is not None else utt_s - end = stored[1] if stored is not None and stored[1] is not None else utt_e + def __repr__(self) -> str: + return f'Textlet({self.text!r})' - if start is None or end is None: - return None - if utt_s is not None: - start = max(start, utt_s) - if utt_e is not None: - end = min(end, utt_e) - return (start, end) +# --------------------------------------------------------------------------- +# EafAnnotation +# --------------------------------------------------------------------------- -class AnnotationView: - """Wraps a single ELAN annotation from pympi's tier store.""" +class EafAnnotation: + __slots__ = ('_id', '_doc') def __init__(self, ann_id: str, doc: MumoDoc) -> None: - self._id = ann_id + self._id = ann_id self._doc = doc @property @@ -179,11 +294,11 @@ def id(self) -> str: @property def tier_name(self) -> str: - return self._doc.annotations[self._id] + return self._doc._elan.annotations[self._id] @property def value(self) -> str: - aligned, ref, _, _ = self._doc.tiers[self.tier_name] + aligned, ref, _, _ = self._doc._elan.tiers[self.tier_name] if self._id in aligned: return aligned[self._id][2] or '' if self._id in ref: @@ -192,356 +307,345 @@ def value(self) -> str: @property def time(self) -> tuple[float, float] | None: - return self._doc._resolve_ann_time(self._id, set()) + return self._doc._resolve_eaf_time(self._id, set()) @property - def parent(self) -> AnnotationView | None: - _, ref, _, _ = self._doc.tiers[self.tier_name] + def parent(self) -> EafAnnotation | None: + _, ref, _, _ = self._doc._elan.tiers[self.tier_name] if self._id in ref: - parent_id = ref[self._id][0] - if parent_id and parent_id in self._doc.annotations: - return AnnotationView(parent_id, self._doc) + pid = ref[self._id][0] + if pid and pid in self._doc._elan.annotations: + return EafAnnotation(pid, self._doc) return None @property - def children(self) -> list[AnnotationView]: + def children(self) -> list[EafAnnotation]: result = [] - for (_, ref, _, _) in self._doc.tiers.values(): + for (_, ref, _, _) in self._doc._elan.tiers.values(): for aid, (parent_id, *_) in ref.items(): if parent_id == self._id: - result.append(AnnotationView(aid, self._doc)) + result.append(EafAnnotation(aid, self._doc)) return result + def __repr__(self) -> str: + return f'EafAnnotation({self.tier_name!r}, {self.value!r})' + + +# --------------------------------------------------------------------------- +# PatternSchema / SlotSchema / MetricSchema +# --------------------------------------------------------------------------- + +class MetricSchema: + __slots__ = ('_rec',) + + def __init__(self, rec: dict) -> None: + self._rec = rec + @property - def constraint(self) -> str | None: - _, _, attrs, _ = self._doc.tiers[self.tier_name] - lt_id = attrs.get('LINGUISTIC_TYPE_REF') - if lt_id and lt_id in self._doc.linguistic_types: - return self._doc.linguistic_types[lt_id].get('CONSTRAINTS') - return None + def id(self) -> str: + return self._rec['id'] @property - def utterance(self) -> object | None: - """The mumo utterance whose time span contains this annotation.""" - t = self.time - if t is None: - return None - s, e = t - for utt in self._doc.all_utterances(): - us, ue = utt.start_time, utt.end_time - if us is not None and ue is not None and us <= e and ue >= s: - return utt - return None + def name(self) -> str: + return self._rec['name'] @property - def frames(self) -> list: - """Frames that have a slot overlapping this annotation's time span.""" - t = self.time - if t is None: - return [] - return self._doc.frames_overlapping(t[0], t[1]) + def type(self) -> str: + return self._rec.get('type', 'text') + def __repr__(self) -> str: + return f'MetricSchema({self.name!r})' -class MetricValue: - def __init__(self, record: dict) -> None: - self._record = record + +class SlotSchema: + __slots__ = ('_rec',) + + def __init__(self, rec: dict) -> None: + self._rec = rec @property - def schema_id(self) -> str: - return self._record['schema_id'] + def id(self) -> str: + return self._rec['id'] @property - def value(self) -> str | None: - return self._record.get('value') + def name(self) -> str: + return self._rec['name'] + @property + def label(self) -> str | None: + return self._rec.get('label') -class SlotView: - def __init__(self, instance: dict, frame_schema: dict | None, doc: MumoDoc) -> None: - self._instance = instance - self._frame_schema = frame_schema - self._doc = doc + @property + def anchor_kind(self) -> str: + return self._rec.get('anchor_kind', 'textlet') + + @property + def required(self) -> bool: + return self._rec.get('required', False) + + @property + def variadic(self) -> bool: + return self._rec.get('variadic', False) + + @property + def metrics(self) -> list[MetricSchema]: + return [MetricSchema(m) for m in self._rec.get('metrics', [])] + + def __repr__(self) -> str: + return f'SlotSchema({self.name!r}, anchor_kind={self.anchor_kind!r})' + + +class PatternSchema: + __slots__ = ('_rec',) + + def __init__(self, rec: dict) -> None: + self._rec = rec @property def id(self) -> str: - return self._instance['id'] + return self._rec['id'] @property - def schema_slot_id(self) -> str: - return self._instance['schema_slot_id'] + def name(self) -> str: + return self._rec['name'] @property - def metrics(self) -> list[MetricValue]: - return [MetricValue(m) for m in self._instance.get('metrics', [])] + def description(self) -> str | None: + return self._rec.get('description') @property - def schema(self) -> dict | None: - if self._frame_schema is None: - return None - return next( - (s for s in self._frame_schema['slots'] if s['id'] == self._instance['schema_slot_id']), - None, - ) + def color(self) -> int | None: + return self._rec.get('color') @property - def anchor_kind(self) -> str | None: - """'utterance', 'span', 'time', or 'frame' - the discriminant for .anchor.""" - s = self.schema - return s['anchor_kind'] if s else None + def hotkey(self) -> str | None: + return self._rec.get('hotkey') @property - def anchor(self) -> UtteranceAnchor | SpanAnchor | TimeAnchor | None: - """ - The resolved content of this slot as a typed value. + def slots(self) -> list[SlotSchema]: + return [SlotSchema(s) for s in self._rec.get('slots', [])] - UtteranceAnchor .utterance - SpanAnchor .utterance .start .end .text - TimeAnchor .start .end - """ - kind = self.anchor_kind - if kind == 'utterance': - utt = self.utterance - if utt is None: - return None - return UtteranceAnchor(kind='utterance', utterance=utt) - if kind == 'span': - sp = self.span - if sp is None: - return None - utt, start, end = sp - return SpanAnchor(kind='span', utterance=utt, start=start, end=end, - text=utt.text[start:end]) - if kind == 'time': - t = self.time - if t is None: - return None - return TimeAnchor(kind='time', start=t[0], end=t[1]) - return None + def slot(self, name: str) -> SlotSchema | None: + return next((s for s in self.slots if s.name == name or s.label == name), None) - # -- Typed content accessors -------------------------------------------------- + def __repr__(self) -> str: + return f'PatternSchema({self.name!r})' - def _textlet(self) -> dict | None: - aid = self._instance.get('annotation_id', '') - return self._doc._mumo['textlets'].get(aid) - def _mark(self) -> dict | None: - tl = self._textlet() - if tl is None: - return None - return self._doc._mumo['marks'].get(tl['mark_id']) +# --------------------------------------------------------------------------- +# MetricValue +# --------------------------------------------------------------------------- + +class MetricValue: + __slots__ = ('_rec', '_slot_schema') + + def __init__(self, rec: dict, slot_schema: SlotSchema | None) -> None: + self._rec = rec + self._slot_schema = slot_schema @property - def utterance(self) -> UtteranceView | None: - """The utterance this slot is anchored to (via textlet mark block_id).""" - mark = self._mark() - if mark: - return self._doc._utterance_view(mark['block_id']) - # fall back to utterance/token-anchored annotation - aid = self._instance.get('annotation_id', '') - ann = self._doc._mumo['annotations'].get(aid) - if ann: - utt_id = ann['features'].get('blockNodeId') or ann['features'].get('utteranceId') - if utt_id: - return self._doc._utterance_view(utt_id) - return None + def schema_id(self) -> str: + return self._rec['schema_id'] @property - def span(self) -> tuple[UtteranceView, int, int] | None: - """ - (utterance, start_char, end_char) for textlet-anchored slots. - Character offsets are into the utterance's plain text (token concatenation). - Returns None if the slot has no textlet mark. - """ - mark = self._mark() - if mark is None: - return None - utt = self._doc._utterance_view(mark['block_id']) - if utt is None: + def name(self) -> str | None: + if self._slot_schema is None: return None - return (utt, mark['start'], mark['end']) + m = next((m for m in self._slot_schema.metrics if m.id == self.schema_id), None) + return m.name if m else None @property - def text(self) -> str | None: - """ - Plain text of the slot's span, reconstructed from tokens. - Returns None if the slot has no textlet mark. - """ - sp = self.span - if sp is None: - return None - utt, start, end = sp - full = utt.text - return full[start:end] + def value(self) -> str | None: + return self._rec.get('value') + + def __repr__(self) -> str: + label = self.name or self.schema_id[:8] + return f'MetricValue({label!r}={self.value!r})' + + +# --------------------------------------------------------------------------- +# Slot (instance) +# --------------------------------------------------------------------------- + +class Slot: + __slots__ = ('_rec', '_schema', '_doc') + + def __init__(self, rec: dict, schema: SlotSchema | None, doc: MumoDoc) -> None: + self._rec = rec + self._schema = schema + self._doc = doc @property - def time(self) -> tuple[float, float] | None: - """ - Resolved time span in seconds. - Tries: EAF annotation -> utterance time. - """ - aid = self._instance.get('annotation_id', '') - # EAF tier annotation - if aid and aid in self._doc.annotations: - t = self._doc._resolve_ann_time(aid, set()) - if t: - return t - # derive from the utterance anchor - utt = self.utterance - if utt and utt.start_time is not None and utt.end_time is not None: - return (utt.start_time, utt.end_time) - return None + def id(self) -> str: + return self._rec['id'] @property - def annotation(self) -> AnnotationView | None: - """The EAF tier annotation this slot points to, if any.""" - aid = self._instance.get('annotation_id', '') - if aid and aid in self._doc.annotations: - return AnnotationView(aid, self._doc) + def name(self) -> str | None: + return self._schema.name if self._schema else None + + @property + def label(self) -> str | None: + return self._schema.label if self._schema else None + + @property + def anchor_kind(self) -> str | None: + return self._schema.anchor_kind if self._schema else None + + @property + def metrics(self) -> list[MetricValue]: + return [MetricValue(m, self._schema) for m in self._rec.get('metrics', [])] + + def __getitem__(self, metric_name: str) -> str | None: + """slot['metric_name'] → metric value.""" + if self._schema: + for ms in self._schema.metrics: + if ms.name == metric_name: + mv = next((m for m in self._rec.get('metrics', []) + if m['schema_id'] == ms.id), None) + return mv['value'] if mv else None return None - def tier_annotations(self, tier: str | None = None) -> list[AnnotationView]: - """ - EAF annotations for this slot via its utterance (structural path): - utterance -> aligned annotations -> their ref/symbolic children. - Falls back to time-overlap if the slot has no utterance anchor. - """ - utt = self.utterance - if utt is not None: - return utt.eaf_annotations(tier=tier) - t = self.time - if t is None: - return [] - return self._doc.annotations_overlapping(t[0], t[1], tier=tier) + # -- anchor resolution --------------------------------------------------- + def _textlet_rec(self) -> dict | None: + aid = self._rec.get('annotation_id', '') + return self._doc._raw['textlets'].get(aid) -class FrameView: - def __init__(self, record: dict, doc: MumoDoc) -> None: - self._record = record - self._doc = doc + def _mark_rec(self) -> dict | None: + tl = self._textlet_rec() + return self._doc._raw['marks'].get(tl['mark_id']) if tl else None @property - def id(self) -> str: - return self._record['id'] + def utterance(self) -> Utterance | None: + mark = self._mark_rec() + if mark: + return self._doc._utt_obj(mark['block_id']) + ann_rec = self._doc._raw['annotations'].get(self._rec.get('annotation_id', '')) + if ann_rec: + uid = ann_rec['features'].get('utteranceId') or ann_rec['features'].get('blockNodeId') + if uid: + utt = self._doc._utt_obj(uid) + return utt + return None @property - def note(self) -> str | None: - return self._record.get('note') + def textlet(self) -> Textlet | None: + tl = self._textlet_rec() + return Textlet(tl, self._doc) if tl else None @property - def schema(self) -> dict | None: - return self._doc._mumo['frame_schemas'].get(self._record['schema_id']) + def text(self) -> str | None: + tl = self.textlet + if tl: + return tl.text + utt = self.utterance + return utt.text if utt else None @property - def slots(self) -> list[SlotView]: - schema = self.schema - return [SlotView(s, schema, self._doc) for s in self._record['slots']] - - def slot(self, name_or_index: str | int) -> SlotView | None: - schema = self.schema - if schema is None: - return None - if isinstance(name_or_index, int): - slot_defs = schema['slots'] - if name_or_index >= len(slot_defs): + def anchor(self) -> UtteranceAnchor | TextletAnchor | TimeAnchor | None: + kind = self.anchor_kind + if kind == 'utterance': + utt = self.utterance + return UtteranceAnchor(kind='utterance', utterance=utt) if utt else None + if kind == 'textlet': + tl = self.textlet + if tl is None: return None - slot_def = slot_defs[name_or_index] - else: - slot_def = next( - (s for s in schema['slots'] - if s['name'] == name_or_index or s.get('label') == name_or_index), - None, - ) - if slot_def is None: - return None - instance = next( - (s for s in self._record['slots'] if s['schema_slot_id'] == slot_def['id']), - None, - ) - if instance is None: + mark = self._mark_rec() + utt = self.utterance + if mark and utt: + return TextletAnchor(kind='textlet', utterance=utt, + start=mark['start'], end=mark['end'], + text=tl.text) return None - return SlotView(instance, schema, self._doc) + if kind == 'time': + t = self.time + return TimeAnchor(kind='time', start=t[0], end=t[1]) if t else None + return None @property def time(self) -> tuple[float, float] | None: - """Bounding time box spanning all slot times in this frame.""" - start = end = None - for sv in self.slots: - t = sv.time - if t is None: - continue - if start is None or t[0] < start: - start = t[0] - if end is None or t[1] > end: - end = t[1] - if start is None or end is None: - return None - return (start, end) + aid = self._rec.get('annotation_id', '') + if aid and aid in self._doc._elan.annotations: + t = self._doc._resolve_eaf_time(aid, set()) + if t: + return t + utt = self.utterance + if utt and utt.start_time is not None and utt.end_time is not None: + return (utt.start_time, utt.end_time) + return None - def tier_annotations(self, tier: str | None = None) -> list[AnnotationView]: - """EAF annotations overlapping any slot in this frame, deduplicated.""" - seen: set = set() - result = [] - for sv in self.slots: - for av in sv.tier_annotations(tier=tier): - if av.id not in seen: - seen.add(av.id) - result.append(av) - return result + def __repr__(self) -> str: + name = self.name or '?' + text = self.text + preview = repr(text[:30]) if text else repr(self.anchor_kind) + return f'Slot({name!r}, {preview})' -class TextletView: - """Wraps a mm:textlet - a reified, named text selection.""" +# --------------------------------------------------------------------------- +# Pattern +# --------------------------------------------------------------------------- - def __init__(self, record: dict, doc: MumoDoc) -> None: - self._record = record +class Pattern: + __slots__ = ('_rec', '_doc') + + def __init__(self, rec: dict, doc: MumoDoc) -> None: + self._rec = rec self._doc = doc @property def id(self) -> str: - return self._record['id'] + return self._rec['id'] @property - def mark(self) -> dict | None: - return self._doc._mumo['marks'].get(self._record['mark_id']) + def schema(self) -> PatternSchema | None: + s = self._doc._raw['pattern_schemas'].get(self._rec['schema_id']) + return PatternSchema(s) if s else None @property - def utterance(self) -> UtteranceView | None: - """The utterance this textlet is anchored to.""" - mark = self.mark - if mark: - return self._doc._utterance_view(mark['block_id']) - return None + def note(self) -> str | None: + return self._rec.get('note') - @property - def span(self) -> tuple[UtteranceView, int, int] | None: - """(utterance, start_char, end_char) from the textlet's mark.""" - mark = self.mark - if mark is None: - return None - utt = self._doc._utterance_view(mark['block_id']) - if utt is None: + def _slot_schema(self, schema_slot_id: str) -> SlotSchema | None: + s = self.schema + if s is None: return None - return (utt, mark['start'], mark['end']) + return next((ss for ss in s.slots if ss.id == schema_slot_id), None) @property - def text(self) -> str | None: - """The plain text this textlet covers.""" - sp = self.span - if sp is None: + def slots(self) -> list[Slot]: + return [Slot(s, self._slot_schema(s['schema_slot_id']), self._doc) + for s in self._rec['slots']] + + def slot(self, name: str) -> Slot | None: + """Get a slot by its schema slot name or label.""" + schema = self.schema + if schema is None: + return None + ss = schema.slot(name) + if ss is None: return None - utt, start, end = sp - return utt.text[start:end] + rec = next((s for s in self._rec['slots'] if s['schema_slot_id'] == ss.id), None) + return Slot(rec, ss, self._doc) if rec else None - @property - def frames(self) -> list[FrameView]: - """All frames that have a slot referencing this textlet.""" - return self._doc.frames_for_textlet(self._record['id']) + def __getitem__(self, name: str) -> Slot | None: + return self.slot(name) - def tier_annotations(self, tier: str | None = None) -> list[AnnotationView]: - """EAF annotations that overlap the utterance time of this textlet.""" - utt = self.utterance - if utt is None: - return [] - s, e = utt.start_time, utt.end_time - if s is None or e is None: - return [] - return self._doc.annotations_overlapping(s, e, tier=tier) + def __iter__(self) -> Iterator[Slot]: + return iter(self.slots) + + def __repr__(self) -> str: + schema_name = self.schema.name if self.schema else '?' + return f'Pattern({schema_name!r}, {len(self._rec["slots"])} slot(s))' + + +# --------------------------------------------------------------------------- +# Backward-compat aliases +# --------------------------------------------------------------------------- + +PatternView = Pattern +FrameView = Pattern +UtteranceView = Utterance +TokenView = Token +TextletView = Textlet +SlotView = Slot diff --git a/pymumo/tests/fixtures/test.mmeaf b/pymumo/tests/fixtures/test.mmeaf index 89374e0..a51f63b 100644 --- a/pymumo/tests/fixtures/test.mmeaf +++ b/pymumo/tests/fixtures/test.mmeaf @@ -598,13 +598,13 @@ - + - + - + @@ -633,23 +633,23 @@ - + thanks - + 1783311184358-5x52273d033c - + 1783311184358-2o3z3w2j6j5t - + 1783311184358-2d265u2p0c4l - + 1783311184358-350d4b5l6i63 diff --git a/pymumo/tests/test_crossref.py b/pymumo/tests/test_crossref.py index b9a28db..bc11a52 100644 --- a/pymumo/tests/test_crossref.py +++ b/pymumo/tests/test_crossref.py @@ -1,177 +1,101 @@ -"""Cross-reference tests: frames <-> EAF tiers <-> textlets.""" -import pytest -from mumo import (MumoDoc, AnnotationView, FrameView, TextletView, - UtteranceView) +"""Cross-reference tests: patterns <-> EAF tiers <-> textlets.""" +from mumo import MumoDoc, EafAnnotation, Pattern, Textlet, Utterance -# -- TextletView -------------------------------------------------------------- +def test_all_textlets_are_textlets(doc): + assert all(isinstance(t, Textlet) for t in doc.textlets) -def test_all_textlets_returns_textlet_views(doc): - textlets = doc.all_textlets() - assert all(isinstance(t, TextletView) for t in textlets) - -def test_textlet_by_id_round_trip(doc): - textlets = doc.all_textlets() - if not textlets: - return - tl = textlets[0] - assert doc.textlet(tl.id) is not None - assert doc.textlet(tl.id).id == tl.id - - -def test_textlet_unknown_returns_none(doc): - assert doc.textlet('__no_such_textlet__') is None - - -def test_textlet_utterance_is_utterance_view(doc): - textlets = [t for t in doc.all_textlets() if t.utterance is not None] - if not textlets: - return - assert isinstance(textlets[0].utterance, UtteranceView) - - -def test_textlet_span_returns_utt_and_offsets(doc): - textlets = [t for t in doc.all_textlets() if t.span is not None] - if not textlets: +def test_textlet_utterance_is_utterance(doc): + tls = [t for t in doc.textlets if t._mark is not None] + if not tls: return - utt, start, end = textlets[0].span - assert isinstance(utt, UtteranceView) - assert isinstance(start, int) and isinstance(end, int) - assert start <= end + assert isinstance(tls[0].utterance, Utterance) -def test_textlet_text_is_substring_of_utterance(doc): - textlets = [t for t in doc.all_textlets() if t.text is not None] - if not textlets: +def test_textlet_span_offsets(doc): + tls = [t for t in doc.textlets if t._mark is not None] + if not tls: return - tl = textlets[0] - assert tl.text in tl.utterance.text + tl = tls[0] + assert isinstance(tl.start, int) and isinstance(tl.end, int) + assert tl.start <= tl.end -def test_textlet_frames_returns_frame_views(doc): - textlets = doc.all_textlets() - for tl in textlets: - assert all(isinstance(f, FrameView) for f in tl.frames) +def test_textlet_text_is_substring(doc): + for tl in doc.textlets: + assert tl.text in tl.utterance.text -# -- frames_for_textlet ------------------------------------------------------- +def test_textlet_patterns(doc): + for tl in doc.textlets: + assert all(isinstance(p, Pattern) for p in tl.patterns) -def test_frames_for_textlet_finds_referencing_frames(doc): - """Every textlet that is referenced by a slot should appear in frames_for_textlet.""" - for frame in doc.all_frames(): - for sv in frame.slots: - tl_id = sv._instance.get('annotation_id', '') - if tl_id and tl_id in doc._mumo['textlets']: - referencing = doc.frames_for_textlet(tl_id) - assert any(f.id == frame.id for f in referencing) +def test_patterns_for_textlet(doc): + for pattern in doc.patterns: + for sv in pattern.slots: + tl = sv.textlet + if tl is not None: + referencing = tl.patterns + assert any(p.id == pattern.id for p in referencing) -# -- annotations_overlapping -------------------------------------------------- -def test_annotations_overlapping_returns_annotation_views(doc): - anns = doc.all_eaf_annotations() +def test_annotations_overlapping_returns_eaf_annotations(doc): + anns = doc.eaf_annotations timed = [a for a in anns if a.time is not None] if not timed: return t = timed[0].time result = doc.annotations_overlapping(t[0], t[1]) assert len(result) > 0 - assert all(isinstance(a, AnnotationView) for a in result) + assert all(isinstance(a, EafAnnotation) for a in result) def test_annotations_overlapping_includes_self(doc): - timed = [a for a in doc.all_eaf_annotations() if a.time is not None] + timed = [a for a in doc.eaf_annotations if a.time is not None] if not timed: return ann = timed[0] s, e = ann.time - result = doc.annotations_overlapping(s, e) - ids = {a.id for a in result} + ids = {a.id for a in doc.annotations_overlapping(s, e)} assert ann.id in ids def test_annotations_overlapping_tier_filter(doc): - timed = [a for a in doc.all_eaf_annotations() if a.time is not None] + timed = [a for a in doc.eaf_annotations if a.time is not None] if not timed: return ann = timed[0] s, e = ann.time - tier = ann.tier_name - filtered = doc.annotations_overlapping(s, e, tier=tier) - assert all(a.tier_name == tier for a in filtered) + filtered = doc.annotations_overlapping(s, e, tier=ann.tier_name) + assert all(a.tier_name == ann.tier_name for a in filtered) -def test_annotations_overlapping_empty_range_returns_empty(doc): - result = doc.annotations_overlapping(1e9, 1e9 + 0.001) - assert result == [] +def test_annotations_overlapping_empty_range(doc): + assert doc.annotations_overlapping(1e9, 1e9 + 0.001) == [] -# -- frames_overlapping ------------------------------------------------------- - -def test_frames_overlapping_returns_frame_views(doc): - if not doc._mumo['frames']: +def test_patterns_overlapping(doc): + if not doc.patterns: return - # Use the time of the first frame that has a time - for fv in doc.all_frames(): - t = fv.time + for pv in doc.patterns: + t = pv.slots[0].time if pv.slots else None if t: - result = doc.frames_overlapping(t[0], t[1]) + result = doc.patterns_overlapping(t[0], t[1]) assert len(result) > 0 - assert all(isinstance(f, FrameView) for f in result) - return - - -def test_frames_overlapping_includes_self(doc): - if not doc._mumo['frames']: - return - for fv in doc.all_frames(): - t = fv.time - if t: - result = doc.frames_overlapping(t[0], t[1]) - assert any(f.id == fv.id for f in result) + assert all(isinstance(p, Pattern) for p in result) return -# -- AnnotationView cross-refs ------------------------------------------------ - -def test_annotation_utterance_is_utterance_view_or_none(doc): - timed = [a for a in doc.all_eaf_annotations() if a.time is not None] - if not timed: - return - utt = timed[0].utterance - assert utt is None or isinstance(utt, UtteranceView) - - -def test_annotation_frames_returns_frame_views(doc): - for ann in doc.all_eaf_annotations(): - assert all(isinstance(f, FrameView) for f in ann.frames) - +def test_pattern_time_is_valid_range(doc): + for pv in doc.patterns: + for slot in pv.slots: + t = slot.time + if t is not None: + assert t[0] <= t[1] -# -- SlotView.tier_annotations ------------------------------------------------ -def test_slot_tier_annotations_returns_annotation_views(doc): - if not doc._mumo['frames']: - return - for fv in doc.all_frames(): - for sv in fv.slots: - anns = sv.tier_annotations() - assert all(isinstance(a, AnnotationView) for a in anns) - return # one slot is enough - - -# -- FrameView cross-refs ----------------------------------------------------- - -def test_frame_time_is_none_or_valid_range(doc): - for fv in doc.all_frames(): - t = fv.time - if t is not None: - assert t[0] <= t[1] - - -def test_frame_tier_annotations_returns_annotation_views(doc): - if not doc._mumo['frames']: - return - for fv in doc.all_frames(): - anns = fv.tier_annotations() - assert all(isinstance(a, AnnotationView) for a in anns) +def test_utterance_patterns(doc): + for utt in doc.utterances: + assert all(isinstance(p, Pattern) for p in utt.patterns) diff --git a/pymumo/tests/test_dataframes.py b/pymumo/tests/test_dataframes.py index 630e470..b95ba73 100644 --- a/pymumo/tests/test_dataframes.py +++ b/pymumo/tests/test_dataframes.py @@ -7,7 +7,7 @@ HAS_PANDAS = False import pytest -from mumo import frames_df, annotations_df +from mumo import patterns_df, frames_df, annotations_df pytestmark = pytest.mark.skipif(not HAS_PANDAS, reason='pandas not installed') @@ -20,7 +20,7 @@ def test_annotations_df_has_expected_columns(doc): def test_annotations_df_row_count_matches(doc): df = annotations_df(doc) - assert len(df) == len(doc.all_eaf_annotations()) + assert len(df) == len(doc.eaf_annotations) def test_annotations_df_tier_filter(doc): @@ -41,11 +41,11 @@ def test_annotations_df_aligned_have_times(doc): assert (aligned['start_time'] <= aligned['end_time']).all() -def test_frames_df_single_schema_returns_dataframe(doc): - if not doc._mumo['frames']: +def test_patterns_df_returns_dataframe(doc): + if not doc.patterns: return - result = frames_df(doc) import pandas as pd + result = patterns_df(doc) if isinstance(result, dict): for df in result.values(): assert isinstance(df, pd.DataFrame) @@ -53,40 +53,43 @@ def test_frames_df_single_schema_returns_dataframe(doc): assert isinstance(result, pd.DataFrame) -def test_frames_df_has_frame_id_and_schema_columns(doc): - if not doc._mumo['frames']: +def test_patterns_df_has_pattern_id_and_schema_columns(doc): + if not doc.patterns: return - result = frames_df(doc) import pandas as pd + result = patterns_df(doc) df = result if isinstance(result, pd.DataFrame) else next(iter(result.values())) - assert 'frame_id' in df.columns - assert 'schema' in df.columns + assert 'pattern_id' in df.columns + assert 'schema' in df.columns -def test_frames_df_named_schema(doc): +def test_patterns_df_named_schema(doc): import pandas as pd - schemas = doc._mumo['frame_schemas'] + schemas = doc.pattern_schemas if not schemas: return - schema_name = next(iter(schemas.values()))['name'] - df = frames_df(doc, schema=schema_name) + schema_name = schemas[0].name + df = patterns_df(doc, schema=schema_name) assert isinstance(df, pd.DataFrame) assert (df['schema'] == schema_name).all() -def test_frames_df_slot_text_column_present(doc): +def test_patterns_df_slot_column_present(doc): import pandas as pd - schemas = doc._mumo['frame_schemas'] + schemas = doc.pattern_schemas if not schemas: return - schema = next(iter(schemas.values())) - if not schema['slots']: + schema = schemas[0] + if not schema.slots: return - df = frames_df(doc, schema=schema['name']) - slot_name = schema['slots'][0]['name'] - assert slot_name in df.columns + df = patterns_df(doc, schema=schema.name) + assert schema.slots[0].name in df.columns -def test_frames_df_unknown_schema_raises(doc): +def test_patterns_df_unknown_schema_raises(doc): with pytest.raises(KeyError): - frames_df(doc, schema='__no_such_schema__') + patterns_df(doc, schema='__no_such_schema__') + + +def test_frames_df_is_alias_for_patterns_df(doc): + assert frames_df is patterns_df diff --git a/pymumo/tests/test_doc.py b/pymumo/tests/test_doc.py index 89f6089..b7ec95e 100644 --- a/pymumo/tests/test_doc.py +++ b/pymumo/tests/test_doc.py @@ -1,180 +1,152 @@ -from mumo import MumoDoc, UtteranceView, TokenView, AnnotationView, FrameView +from mumo import MumoDoc, Utterance, Token, Pattern, Textlet +import pytest -def test_all_utterances_nonempty_and_sorted(doc): - utts = doc.all_utterances() +def test_utterances_nonempty_and_sorted(doc): + utts = doc.utterances assert len(utts) > 0 + assert all(isinstance(u, Utterance) for u in utts) orders = [u.order for u in utts] assert orders == sorted(orders) def test_utterance_has_participant_and_time(doc): - utt = doc.all_utterances()[0] + utt = doc.utterances[0] assert isinstance(utt.participant, str) and utt.participant - assert utt.start_time is not None - assert utt.end_time is not None - assert utt.start_time <= utt.end_time + assert utt.start_time is None or isinstance(utt.start_time, float) def test_utterance_text_matches_token_concatenation(doc): - utt = doc.all_utterances()[0] - assert utt.text == ''.join(t.text for t in utt.tokens) + for utt in doc.utterances: + assert utt.text == ''.join(t.text for t in utt.tokens) -def test_utterance_by_id_round_trip(doc): - uid = doc.all_utterances()[0].id - assert doc.utterance(uid) is not None - assert doc.utterance(uid).id == uid +def test_utterance_lookup_by_id(doc): + utt = doc.utterances[0] + assert doc.utterance(utt.id).id == utt.id + assert doc[utt.id].id == utt.id def test_utterance_unknown_id_returns_none(doc): - assert doc.utterance('no-such-utt') is None + assert doc.utterance('__nope__') is None -def test_token_by_id_round_trip(doc): - tok = doc.all_tokens()[0] - assert doc.token(tok.id) is not None - assert doc.token(tok.id).id == tok.id +def test_utterance_words_excludes_whitespace(doc): + for utt in doc.utterances: + for tok in utt.words: + assert tok.kind != 'ws' -def test_token_unknown_id_returns_none(doc): - assert doc.token('no-such-token') is None +def test_utterance_continuations(doc): + for utt in doc.utterances: + if utt.is_continuation: + assert utt.head is not None + assert isinstance(utt.head, Utterance) + assert utt in utt.head.continuations + else: + assert utt.head is None -def test_token_links_back_to_utterance(doc): - tok = next(t for t in doc.all_tokens() if t.kind == 'word') - assert tok.utterance is not None - assert isinstance(tok.utterance, UtteranceView) - - -def test_token_index_among_non_ws(doc): - utt = next(u for u in doc.all_utterances() if sum(1 for t in u.tokens if t.kind != 'ws') >= 3) - word_toks = [t for t in utt.tokens if t.kind != 'ws'] - for i, t in enumerate(word_toks): - assert t.index == i +def test_utterance_chain_starts_at_head(doc): + for utt in doc.utterances: + chain = utt.chain + assert len(chain) >= 1 + assert not chain[0].is_continuation -def test_all_tokens_count_equals_sum_of_utterance_tokens(doc): - total = sum(len(u.tokens) for u in doc.all_utterances()) - assert total == len(doc.all_tokens()) +def test_token_links_back_to_utterance(doc): + for utt in doc.utterances[:3]: + for tok in utt.tokens: + assert tok.utterance.id == utt.id -def test_token_bounded_time_uses_utterance_when_no_token_time(doc): - tok = next(t for t in doc.all_tokens() if t.kind == 'word' and t.time is None) - utt = tok.utterance - if utt and utt.start_time is not None and utt.end_time is not None: - bt = tok.bounded_time - assert bt is not None - assert bt[0] == utt.start_time - assert bt[1] == utt.end_time +def test_patterns_are_pattern_objects(doc): + assert all(isinstance(p, Pattern) for p in doc.patterns) -def test_eaf_annotation_unknown_returns_none(doc): - assert doc.eaf_annotation('no-such-id') is None +def test_pattern_lookup_by_id(doc): + patterns = doc.patterns + if not patterns: + return + p = patterns[0] + assert doc.pattern(p.id).id == p.id + assert doc[p.id].id == p.id -def test_all_eaf_annotations_nonempty(doc): - anns = doc.all_eaf_annotations() - assert len(anns) > 0 - assert all(isinstance(a, AnnotationView) for a in anns) +def test_pattern_schema_accessible(doc): + for p in doc.patterns: + assert p.schema is not None -def test_eaf_annotation_value_is_string(doc): - ann = doc.all_eaf_annotations()[0] - assert isinstance(ann.value, str) +def test_pattern_iteration(doc): + for p in doc.patterns: + for slot in p: + assert slot.name is not None -def test_aligned_annotation_has_time(doc): - ann = next((a for a in doc.all_eaf_annotations() if a.time is not None), None) - assert ann is not None - s, e = ann.time - assert isinstance(s, float) - assert s <= e +def test_pattern_slot_by_name(doc): + for p in doc.patterns: + for slot in p.slots: + if slot.name: + found = p[slot.name] + assert found is not None + assert found.name == slot.name -def test_ref_annotation_inherits_time(doc): - ref_ann = next( - (a for a in doc.all_eaf_annotations() if a.parent is not None), +def _pattern_with_textlet_slots(doc): + return next( + (p for p in doc.patterns if any(s._textlet_rec() for s in p.slots)), None, ) - if ref_ann is None: - return # file has no ref annotations - skip - parent_time = ref_ann.parent.time - child_time = ref_ann.time - if parent_time and child_time: - assert child_time == parent_time - - -def test_frame_unknown_returns_none(doc): - assert doc.frame('nope') is None -def test_all_frames_are_frame_views(doc): - frames = doc.all_frames() - assert all(isinstance(f, FrameView) for f in frames) - +def test_slot_utterance_resolves(doc): + p = _pattern_with_textlet_slots(doc) + if p is None: + return + slot = next(s for s in p.slots if s._textlet_rec()) + assert isinstance(slot.utterance, Utterance) -# -- SlotView content resolution ---------------------------------------------- -def _frame_with_textlet_slots(doc): - """Return a frame that has at least one textlet-backed slot.""" - return next( - (f for f in doc.all_frames() if any(s._textlet() for s in f.slots)), - None, - ) +def test_slot_text_is_substring_of_utterance(doc): + p = _pattern_with_textlet_slots(doc) + if p is None: + return + for slot in (s for s in p.slots if s._textlet_rec()): + assert slot.text in slot.utterance.text -def test_slot_utterance_resolves_via_textlet(doc): - frame = _frame_with_textlet_slots(doc) - if frame is None: +def test_slot_time_derives_from_utterance(doc): + p = _pattern_with_textlet_slots(doc) + if p is None: return - slot = next(s for s in frame.slots if s._textlet()) - utt = slot.utterance - assert utt is not None - assert isinstance(utt, UtteranceView) + for slot in (s for s in p.slots if s._textlet_rec()): + assert slot.time is not None -def test_slot_span_returns_utt_and_char_range(doc): - frame = _frame_with_textlet_slots(doc) - if frame is None: - return - slot = next(s for s in frame.slots if s._textlet()) - utt, start, end = slot.span - assert isinstance(utt, UtteranceView) - assert isinstance(start, int) - assert isinstance(end, int) - assert start <= end +def test_textlets_are_textlet_objects(doc): + assert all(isinstance(tl, Textlet) for tl in doc.textlets) -def test_slot_text_is_substring_of_utterance(doc): - frame = _frame_with_textlet_slots(doc) - if frame is None: +def test_textlet_lookup(doc): + tls = doc.textlets + if not tls: return - for slot in (s for s in frame.slots if s._textlet()): - text = slot.text - assert text is not None - assert text in slot.utterance.text + tl = tls[0] + assert doc.textlet(tl.id).id == tl.id + assert doc[tl.id].id == tl.id -def test_slot_time_derives_from_utterance(doc): - frame = _frame_with_textlet_slots(doc) - if frame is None: - return - for slot in (s for s in frame.slots if s._textlet()): - t = slot.time - if t is not None: - s, e = t - assert isinstance(s, float) - assert s <= e +def test_textlet_text_is_substring_of_utterance(doc): + for tl in doc.textlets: + assert tl.text in tl.utterance.text -def test_marks_and_textlets_parsed(doc): - assert len(doc._mumo['marks']) > 0 - assert len(doc._mumo['textlets']) > 0 +def test_textlet_patterns_returns_patterns(doc): + for tl in doc.textlets: + assert all(isinstance(p, Pattern) for p in tl.patterns) -def test_textlet_mark_block_id_references_known_utterance(doc): - for tl in doc._mumo['textlets'].values(): - mark = doc._mumo['marks'].get(tl['mark_id']) - assert mark is not None - assert doc.utterance(mark['block_id']) is not None +def test_getitem_unknown_raises(doc): + with pytest.raises(KeyError): + doc['__nope__'] diff --git a/pymumo/tests/test_sqlite.py b/pymumo/tests/test_sqlite.py index 66d3cb7..cb52abe 100644 --- a/pymumo/tests/test_sqlite.py +++ b/pymumo/tests/test_sqlite.py @@ -10,8 +10,8 @@ def test_export_creates_all_tables(doc, tmp_path): "SELECT name FROM sqlite_master WHERE type='table'" ).fetchall()} con.close() - for expected in ('documents', 'utterances', 'tokens', 'frame_schemas', - 'frames', 'slot_instances', 'eaf_tiers', 'eaf_annotations'): + for expected in ('documents', 'utterances', 'tokens', 'pattern_schemas', + 'patterns', 'slot_instances', 'eaf_tiers', 'eaf_annotations'): assert expected in tables, f'missing table: {expected}' @@ -30,7 +30,7 @@ def test_export_utterance_count(doc, tmp_path): con = sqlite3.connect(db) count = con.execute("SELECT COUNT(*) FROM utterances WHERE document_id='test'").fetchone()[0] con.close() - assert count == len(doc.all_utterances()) + assert count == len(doc.utterances) def test_export_token_count(doc, tmp_path): @@ -39,7 +39,7 @@ def test_export_token_count(doc, tmp_path): con = sqlite3.connect(db) count = con.execute("SELECT COUNT(*) FROM tokens WHERE document_id='test'").fetchone()[0] con.close() - assert count == len(doc.all_tokens()) + assert count == len(doc._raw['tokens']) def test_export_eaf_annotation_count(doc, tmp_path): @@ -50,7 +50,7 @@ def test_export_eaf_annotation_count(doc, tmp_path): "SELECT COUNT(*) FROM eaf_annotations WHERE document_id='test'" ).fetchone()[0] con.close() - assert count == len(doc.all_eaf_annotations()) + assert count == len(doc.eaf_annotations) def test_export_idempotent(doc, tmp_path): @@ -60,7 +60,7 @@ def test_export_idempotent(doc, tmp_path): con = sqlite3.connect(db) count = con.execute("SELECT COUNT(*) FROM utterances WHERE document_id='test'").fetchone()[0] con.close() - assert count == len(doc.all_utterances()) + assert count == len(doc.utterances) def test_token_positions_are_sequential_per_utterance(doc, tmp_path): diff --git a/scripts/migrate_mmeaf.py b/scripts/migrate_mmeaf.py new file mode 100644 index 0000000..a532d7d --- /dev/null +++ b/scripts/migrate_mmeaf.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +""" +Migrate mmeaf files to current schema conventions. + +Changes applied (all idempotent): + 1. anchor_kind="span" → anchor_kind="textlet" in mm:slot elements + 2. mm:frame_schemas / mm:frame_schema / mm:frames / mm:frame element names + → mm:pattern_schemas / mm:pattern_schema / mm:patterns / mm:pattern + 3. EAF token tier IDs: TIER_ID="tokens:" → TIER_ID="tokens:utterance:" + and matching PARENT_REF="tokens:" → PARENT_REF="tokens:utterance:" + (only renames tiers whose token-suffix contains no colon, i.e. the old flat format) +""" +from __future__ import annotations + +import argparse +import re +import shutil +import sys +from pathlib import Path + + +# 1. anchor_kind="span" → "textlet" inside mm:slot opening tags +_SLOT_SPAN_RE = re.compile( + r'(]*?\banchor_kind=)"span"', + re.DOTALL, +) + +# 2. Element renames – applied in longest-first order so no partial match issues. +# Each tuple is (old_token, new_token); simple string replacement on the whole text. +_ELEMENT_RENAMES: list[tuple[str, str]] = [ + ('mm:frame_schemas', 'mm:pattern_schemas'), + ('mm:frame_schema', 'mm:pattern_schema'), + ('mm:frames', 'mm:patterns'), + ('mm:frame', 'mm:pattern'), +] + +# 3. Token tier renames: TIER_ID="tokens:" → "tokens:utterance:" +# The negative lookahead (?!utterance:) prevents double-migration. +_TOKEN_TIER_RE = re.compile( + r'((?:TIER_ID|PARENT_REF)="tokens:)(?!utterance:)([^"]+)(")', +) + + +def migrate_text(text: str) -> tuple[str, int]: + """Return (migrated_text, total_change_count).""" + count = 0 + + # 1. anchor_kind + text, n = _SLOT_SPAN_RE.subn(r'\1"textlet"', text) + count += n + + # 2. element renames + for old, new in _ELEMENT_RENAMES: + new_text = text.replace(old, new) + if new_text != text: + count += text.count(old) + text = new_text + + # 3. token tier renames + text, n = _TOKEN_TIER_RE.subn(r'\1utterance:\2\3', text) + count += n + + return text, count + + +def migrate_file(path: Path, *, dry_run: bool, backup: bool) -> bool: + """Migrate one file in-place. Returns True if any changes were made.""" + original = path.read_text(encoding='utf-8') + migrated, count = migrate_text(original) + if count == 0: + return False + + print(f'{path}: {count} change(s)') + if dry_run: + return True + + if backup: + shutil.copy2(path, path.with_suffix(path.suffix + '.bak')) + + path.write_text(migrated, encoding='utf-8') + return True + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('paths', nargs='+', help='mmeaf files or directories to migrate') + parser.add_argument('--dry-run', '-n', action='store_true', + help='Print what would change without writing files') + parser.add_argument('--no-backup', action='store_true', + help='Skip creating .bak backups before overwriting') + args = parser.parse_args() + + changed = 0 + unchanged = 0 + + for raw in args.paths: + p = Path(raw) + candidates: list[Path] = [] + if p.is_dir(): + candidates = sorted(p.rglob('*.mmeaf')) + elif p.is_file(): + candidates = [p] + else: + print(f'warning: {p} not found, skipping', file=sys.stderr) + continue + + for f in candidates: + if migrate_file(f, dry_run=args.dry_run, backup=not args.no_backup): + changed += 1 + else: + unchanged += 1 + + suffix = ' (dry run)' if args.dry_run else '' + print(f'\n{changed} file(s) updated, {unchanged} already up to date{suffix}') + + +if __name__ == '__main__': + main()