feat: enhance diarization skipped job logging with meeting creator ID and improve dev server robustness with execvp error handling.

This commit is contained in:
2026-01-19 08:59:30 +00:00
parent 552519ca7a
commit fbe7fc66d8
8 changed files with 91 additions and 25 deletions

View File

@@ -121,16 +121,43 @@ pub fn get_effective_server_url(state: State<'_, Arc<AppState>>) -> EffectiveSer
// Otherwise, use config (which tracks env vs default)
let default_url = cfg.server.default_address.clone();
let (h, p) = if let Some(pos) = default_url.find(':') {
(&default_url[..pos], &default_url[pos + 1..])
} else {
(&default_url[..], "")
};
let (host, port) = parse_host_port(&default_url);
EffectiveServerUrl {
url: default_url.clone(),
host: h.to_string(),
port: p.to_string(),
host,
port,
source: cfg.server.address_source,
}
}
fn parse_host_port(address: &str) -> (String, String) {
let trimmed = address.trim();
if trimmed.is_empty() {
return (String::new(), String::new());
}
if let Some(rest) = trimmed.strip_prefix('[') {
if let Some(end) = rest.find(']') {
let host = &rest[..end];
let port = rest[end + 1..].strip_prefix(':').unwrap_or("");
return (host.to_string(), sanitize_port(port));
}
}
if let Some((host, port)) = trimmed.rsplit_once(':') {
if !host.is_empty() && !port.is_empty() && !host.contains(':') {
return (host.to_string(), sanitize_port(port));
}
}
(trimmed.to_string(), String::new())
}
fn sanitize_port(port: &str) -> String {
if port.parse::<u16>().is_ok() {
port.to_string()
} else {
String::new()
}
}

View File

@@ -14,23 +14,23 @@ vi.mock('@/hooks/use-toast', () => ({
}));
describe('TranscriptSegmentActions', () => {
it('renders copy button and handles click', () => {
it('renders copy button and handles click', async () => {
render(<TranscriptSegmentActions segmentText="Hello world" />);
const copyButton = screen.getByTitle('Copy text');
expect(copyButton).toBeDefined();
fireEvent.click(copyButton);
await fireEvent.click(copyButton);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Hello world');
});
it('renders play button when onPlay is provided', () => {
const onPlay = vi.fn();
render(<TranscriptSegmentActions segmentText="Hello world" onPlay={onPlay} />);
const playButton = screen.getByTitle('Play audio');
expect(playButton).toBeDefined();
fireEvent.click(playButton);
expect(onPlay).toHaveBeenCalled();
});

View File

@@ -9,6 +9,10 @@ export interface TranscriptSegmentActionsProps {
export function TranscriptSegmentActions({ segmentText, onPlay }: TranscriptSegmentActionsProps) {
const handleCopy = async () => {
if (!navigator.clipboard || typeof navigator.clipboard.writeText !== 'function') {
toast({ description: 'Clipboard not supported', variant: 'destructive' });
return;
}
try {
await navigator.clipboard.writeText(segmentText);
toast({ description: 'Text copied to clipboard' });

View File

@@ -76,11 +76,22 @@ function normalizeSizes(prefs: PanelPreferences): PanelPreferences {
const visibleSidePanels = (showNotesPanel ? 1 : 0) + (showStatsPanel ? 1 : 0);
if (visibleSidePanels === SIDE_PANEL_COUNT) {
const sideSpace = (remaining - SIDE_PANEL_COUNT * COLLAPSED_SIZE_PERCENT) / SIDE_PANEL_COUNT;
const totalMinSide = MIN_NOTES_SIZE_PERCENT + MIN_STATS_SIZE_PERCENT;
let newNotesSize = MIN_NOTES_SIZE_PERCENT;
let newStatsSize = MIN_STATS_SIZE_PERCENT;
if (remaining > totalMinSide) {
const excess = remaining - totalMinSide;
const totalPref = clampedNotesSize + clampedStatsSize;
const notesRatio = totalPref > 0 ? clampedNotesSize / totalPref : 0.5;
newNotesSize += excess * notesRatio;
newStatsSize += excess * (1 - notesRatio);
}
return {
...prefs,
notesPanelSize: Math.max(sideSpace, MIN_NOTES_SIZE_PERCENT),
statsPanelSize: Math.max(sideSpace, MIN_STATS_SIZE_PERCENT),
notesPanelSize: clamp(newNotesSize, MIN_NOTES_SIZE_PERCENT, MAX_NOTES_SIZE_PERCENT),
statsPanelSize: clamp(newStatsSize, MIN_STATS_SIZE_PERCENT, MAX_STATS_SIZE_PERCENT),
transcriptPanelSize: transcriptSize,
};
}

View File

@@ -158,7 +158,7 @@ export default function RecordingPage() {
const handleJumpToLive = useCallback(() => {
const scrollElement = transcriptScrollRef.current;
if (scrollElement) {
scrollElement.scrollTo({ top: scrollElement.scrollHeight, behavior: 'smooth' });
scrollElement.scrollTo({ top: scrollElement.scrollHeight, behavior: 'auto' });
}
}, []);

View File

@@ -29,7 +29,7 @@
"include": [
"client/src",
"src/",
"clinet/src-tauri"
"client/src-tauri"
],
"ignore": {
@@ -110,4 +110,4 @@
"tokenCount": {
"encoding": "o200k_base"
}
}
}

View File

@@ -73,7 +73,11 @@ def run_server() -> None:
nested subprocess spawning that causes zombie processes when watchfiles
restarts on file changes.
"""
os.execvp(sys.executable, [sys.executable, "-m", "noteflow.grpc.server"])
try:
os.execvp(sys.executable, [sys.executable, "-m", "noteflow.grpc.server"])
except OSError as exc:
print(f"Failed to exec gRPC server: {exc}", file=sys.stderr)
raise SystemExit(1) from exc
def main() -> None:

View File

@@ -30,6 +30,21 @@ def _set_diarization_span_attributes(span: Span, ctx: DiarizationJobContext) ->
span.set_attribute("diarization.num_speakers", ctx.num_speakers)
async def _load_meeting_creator_id(ctx: DiarizationJobContext) -> str | None:
"""Best-effort lookup of meeting creator for audit context."""
from ..converters import parse_meeting_id_or_none
meeting_id = parse_meeting_id_or_none(ctx.meeting_id)
if meeting_id is None:
return None
async with ctx.host.create_repository_provider() as repo:
meeting = await repo.meetings.get(meeting_id)
if meeting is None or meeting.created_by_id is None:
return None
return str(meeting.created_by_id)
async def _run_diarization_refinement(
ctx: DiarizationJobContext,
span: Span,
@@ -79,19 +94,24 @@ async def _execute_diarization_with_span(
span.set_attribute("diarization.cancelled", True)
await ctx.host.handle_job_cancelled(ctx.job_id, ctx.job, ctx.meeting_id)
raise # Re-raise to propagate cancellation
except NoDiarizationAudioError as exc:
except NoDiarizationAudioError:
# Expected for simulated transcripts or meetings without audio capture.
# Mark as completed with 0 updates rather than failed.
skip_reason = "no_audio"
span.set_attribute("diarization.skipped", True)
span.set_attribute("diarization.skip_reason", str(exc))
span.set_attribute("diarization.skip_reason", skip_reason)
created_by_id = await _load_meeting_creator_id(ctx)
logger.info(
"Diarization skipped for meeting %s: %s",
ctx.meeting_id,
exc,
"diarization_skipped",
job_id=ctx.job_id,
meeting_id=ctx.meeting_id,
reason=skip_reason,
created_by_id=created_by_id,
)
# Complete the job with 0 updates - this is a valid "nothing to do" state
await ctx.host.update_job_completed(ctx.job_id, ctx.job, 0, [])
span.add_event("job_completed_no_audio")
return
# INTENTIONAL BROAD HANDLER: Job error boundary
# - Diarization can fail in many ways (model errors, audio issues, etc.)
# - Must capture any failure and update job status