@tricoteuses/transcription-videos 1.7.2 → 1.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,9 @@ export declare const ENV: {
11
11
  DG_MIN_SEGMENT_MS: number;
12
12
  DG_USE_PARAGRAPHS: string;
13
13
  ENABLE_IDENTIFICATION: string;
14
+ IDENTIFICATION_MIN_PUBLISH_STRENGTH: "weak" | "strong";
15
+ IDENTIFICATION_LLM_WINDOW_MS: number;
16
+ IDENTIFICATION_CORROBORATION_RUNS: number;
14
17
  ASSEMBLYAI_API_KEY?: string | undefined;
15
18
  ASSEMBLYAI_BASE_URL?: string | undefined;
16
19
  DEEPGRAM_API_KEY?: string | undefined;
@@ -21,6 +21,9 @@ const EnvSchema = z.object({
21
21
  LLM_BASE_URL: z.string().url().optional(),
22
22
  IDENTIFICATION_MODEL: z.string().optional(),
23
23
  ENABLE_IDENTIFICATION: z.string().default('false'),
24
+ IDENTIFICATION_MIN_PUBLISH_STRENGTH: z.enum(['weak', 'strong']).default('strong'),
25
+ IDENTIFICATION_LLM_WINDOW_MS: z.coerce.number().default(200_000),
26
+ IDENTIFICATION_CORROBORATION_RUNS: z.coerce.number().default(5),
24
27
  });
25
28
  export const ENV = EnvSchema.superRefine((env, ctx) => {
26
29
  const providerKeys = {
@@ -6,6 +6,7 @@ import { describeChatModel } from './debug.js';
6
6
  import { invokeStructuredWithRetry } from './llm-retry.js';
7
7
  import { canonicalizeDiarizationKey, segmentDiarizationKey } from './keys.js';
8
8
  import { createNameRegistry, resolveChatModel, selectWindows, validateMergeProposals } from './llm-shared.js';
9
+ import { ENV } from '../../../config/env.js';
9
10
  import { suspectMergePairs } from '../candidates.js';
10
11
  /** Re-exported for existing importers (factory, parliament adapter, tests). */
11
12
  export { buildIdentificationUserPrompt, buildOpenIdentificationUserPrompt, identificationSystemPrompt, openIdentificationSystemPrompt, } from './prompt.js';
@@ -17,7 +18,8 @@ const llmMergeSchema = z.object({
17
18
  evidence: z.string(),
18
19
  });
19
20
  /** Legacy whole-key entry: one identity for the entire diarization key.
20
- * Absent confidence keeps the historical trust rule (→ 'strong'). `id` is
21
+ * Without an explicit `confidence` the entry stays 'weak' (provisional);
22
+ * explicit 'high' is required for 'strong'. `id` is
21
23
  * null when the model attributes a free name (no roster, see `name`). */
22
24
  const llmWholeKeySchema = z.object({
23
25
  key: z.string(),
@@ -104,7 +106,7 @@ function selectResidualSegments(segments, knownMapping, knownWindows) {
104
106
  * announcement-backed mapping is safe).
105
107
  */
106
108
  export function createLlmBackend(options = {}) {
107
- const windowMs = options.windowMs ?? 90_000;
109
+ const windowMs = options.windowMs ?? ENV.IDENTIFICATION_LLM_WINDOW_MS;
108
110
  const maxSegments = options.maxSegments ?? 300;
109
111
  const chunkOverlap = Math.min(Math.max(options.chunkOverlap ?? 10, 0), Math.max(maxSegments - 1, 0));
110
112
  const chunkMode = options.chunkMode ?? 'all';
@@ -266,8 +268,11 @@ export function createLlmBackend(options = {}) {
266
268
  log(response.merges);
267
269
  acceptedMerges.push(...validateMergeProposals({ merges: response.merges, knownKeys, candidatePairs, seenMergeSources, log }));
268
270
  const byId = new Map(roster.map((r) => [r.id, r]));
269
- // Whole-key entries (legacy shape, still accepted): confidence
270
- // absent keeps the historical trust rule (strong) for roster ids.
271
+ // Whole-key entries (legacy shape, still accepted): a roster id is
272
+ // 'strong' only on explicit 'high' confidence. Without an explicit
273
+ // flag the claim stays 'weak' (provisional): an unmarked whole-key
274
+ // attribution easily binds a whole session on thin evidence, and the
275
+ // engine never dethrones a 'strong' incumbent.
271
276
  // A `name` replaces the roster id (free-name attribution): the
272
277
  // person is absent from the (possibly partial) list.
273
278
  for (const m of response.mapping) {
@@ -304,9 +309,9 @@ export function createLlmBackend(options = {}) {
304
309
  }
305
310
  const strength = isFreeName
306
311
  ? freeNameStrength(m.confidence === 'low' ? 'low' : 'high')
307
- : m.confidence === 'low'
308
- ? 'weak'
309
- : 'strong';
312
+ : m.confidence === 'high'
313
+ ? 'strong'
314
+ : 'weak';
310
315
  if (existing && existing.strength === 'strong')
311
316
  continue;
312
317
  accumulated[key] = {
@@ -1,4 +1,4 @@
1
- import type { AbsoluteSegment, Claim, DecisionBackend, PersonInterval, RelativeSegment, RosterEntry } from './types.js';
1
+ import type { AbsoluteSegment, Claim, ClaimStrength, DecisionBackend, PersonInterval, RelativeSegment, RosterEntry } from './types.js';
2
2
  /** Evidence fetched (and parsed) by the caller before each identification run. */
3
3
  export type IdentificationContext = {
4
4
  people?: PersonInterval[];
@@ -21,6 +21,17 @@ export type SpeakerIdentificationEngineArgs = {
21
21
  newKeyDebounceMs?: number;
22
22
  /** Initial offset applied at ingestion to reach the evidence timebase, in ms. */
23
23
  timeOffsetMs?: number;
24
+ /** Minimum claim strength exposed to labels and downstream pushes.
25
+ * Claims below the threshold stay internal: they still feed later runs
26
+ * (knownMapping continuity) so the cascade can upgrade them, but they
27
+ * are never published. Default 'weak' (no filtering). */
28
+ minPublishStrength?: ClaimStrength;
29
+ /** Corroboration: a flat claim holding the same identity for this many
30
+ * consecutive runs is promoted weak→strong (published). A backend
31
+ * proposing a different identity for the key resets the streak. Below 2
32
+ * disables. Default 0 (off); the live path injects
33
+ * IDENTIFICATION_CORROBORATION_RUNS. */
34
+ corroborationRuns?: number;
24
35
  debug?: boolean;
25
36
  };
26
37
  /** Mode-agnostic speaker identification engine shared by media (batch) and
@@ -47,6 +58,9 @@ export declare class SpeakerIdentificationEngine {
47
58
  * each cascade run; windowed claims take precedence over the flat mapping
48
59
  * for segments inside their [startMs, endMs] window. */
49
60
  private windowedMapping;
61
+ /** Corroboration streaks: identity fingerprint + consecutive-run count per key. */
62
+ private claimStreakFp;
63
+ private claimStreakRuns;
50
64
  /** Consecutive runCascade() invocations whose fetchContext returned null
51
65
  * (reset on success). Drives the throttled visibility warning. */
52
66
  private contextFailCount;
@@ -79,6 +93,10 @@ export declare class SpeakerIdentificationEngine {
79
93
  * a new attempt whose time base changes). Keeps nothing across the reset. */
80
94
  reset(): void;
81
95
  get mapping(): Map<string, Claim>;
96
+ /** Whether a claim reaches labels and downstream pushes. */
97
+ private isPublishable;
98
+ /** Published projection of one key (a changed projection fires the push flag). */
99
+ private publishedProjection;
82
100
  /** Resolve one key at a point in time (evidence timebase, ms): windowed
83
101
  * claims (heterogeneous-key splits) take precedence over the flat mapping,
84
102
  * mirroring relabelSegments (the matching window contains the segment
@@ -1,6 +1,9 @@
1
1
  import { fallbackSpeakerLabel } from './identify.js';
2
2
  import { collapseSplitSpeakers } from './identify.js';
3
3
  import { normalizeDecideResult } from './types.js';
4
+ /** Publication rank: provisional ('weak') claims stay internal until a
5
+ * backend upgrades them. */
6
+ const PUBLISH_RANK = { weak: 1, strong: 2 };
4
7
  function normalizeOffsetMs(ms) {
5
8
  return ms != null && Number.isFinite(ms) ? Math.max(0, Math.round(ms)) : 0;
6
9
  }
@@ -28,6 +31,9 @@ export class SpeakerIdentificationEngine {
28
31
  * each cascade run; windowed claims take precedence over the flat mapping
29
32
  * for segments inside their [startMs, endMs] window. */
30
33
  windowedMapping = new Map();
34
+ /** Corroboration streaks: identity fingerprint + consecutive-run count per key. */
35
+ claimStreakFp = new Map();
36
+ claimStreakRuns = new Map();
31
37
  /** Consecutive runCascade() invocations whose fetchContext returned null
32
38
  * (reset on success). Drives the throttled visibility warning. */
33
39
  contextFailCount = 0;
@@ -157,7 +163,28 @@ export class SpeakerIdentificationEngine {
157
163
  this.lastTriggerEndMs = 0;
158
164
  }
159
165
  get mapping() {
160
- return this.currentMapping;
166
+ const min = this.args.minPublishStrength ?? 'weak';
167
+ if (min === 'weak')
168
+ return this.currentMapping;
169
+ const out = new Map();
170
+ for (const [key, entry] of this.currentMapping) {
171
+ if (PUBLISH_RANK[entry.strength] >= PUBLISH_RANK[min])
172
+ out.set(key, entry);
173
+ }
174
+ return out;
175
+ }
176
+ /** Whether a claim reaches labels and downstream pushes. */
177
+ isPublishable(entry) {
178
+ if (!entry)
179
+ return false;
180
+ const min = this.args.minPublishStrength ?? 'weak';
181
+ return PUBLISH_RANK[entry.strength] >= PUBLISH_RANK[min];
182
+ }
183
+ /** Published projection of one key (a changed projection fires the push flag). */
184
+ publishedProjection(entry) {
185
+ if (!this.isPublishable(entry))
186
+ return undefined;
187
+ return `${entry.speakerId}|${entry.label}|${entry.strength}|${entry.source ?? ''}`;
161
188
  }
162
189
  /** Resolve one key at a point in time (evidence timebase, ms): windowed
163
190
  * claims (heterogeneous-key splits) take precedence over the flat mapping,
@@ -171,9 +198,10 @@ export class SpeakerIdentificationEngine {
171
198
  const mid = (startMs + (endMs ?? startMs)) / 2;
172
199
  const windows = this.windowedMapping.get(canonical);
173
200
  const hit = windows?.find((w) => (w.startMs ?? Number.NEGATIVE_INFINITY) <= mid && mid <= (w.endMs ?? Number.POSITIVE_INFINITY));
174
- if (hit)
201
+ if (hit && this.isPublishable(hit))
175
202
  return hit;
176
- return this.currentMapping.get(canonical);
203
+ const flat = this.currentMapping.get(canonical);
204
+ return this.isPublishable(flat) ? flat : undefined;
177
205
  }
178
206
  /** Read-once: returns true when the mapping changed since the previous read. */
179
207
  get hasMappingChanged() {
@@ -232,6 +260,9 @@ export class SpeakerIdentificationEngine {
232
260
  this.log('No accumulated segments → impossible to run identification');
233
261
  return;
234
262
  }
263
+ // Corroboration input: identity proposed per key during this run (flat
264
+ // claims only — windowed claims are bounded in time and never promoted).
265
+ const proposedFp = new Map();
235
266
  // Short-circuit: a 'strong' claim is never dethroned (see beatsIncumbent),
236
267
  // so once every known diarization key holds one, no backend can change the
237
268
  // mapping — skip the run entirely (no NVS fetch, no LLM call). A new key
@@ -290,6 +321,7 @@ export class SpeakerIdentificationEngine {
290
321
  // runs already established for the same key.
291
322
  const existing = this.windowedMapping.get(key) ?? [];
292
323
  const merged = [...existing];
324
+ let addedPublished = false;
293
325
  for (const incoming of claims) {
294
326
  const conflict = merged.find((k) => k.speakerId !== incoming.speakerId &&
295
327
  (k.startMs ?? Number.NEGATIVE_INFINITY) < (incoming.endMs ?? Number.POSITIVE_INFINITY) &&
@@ -301,6 +333,8 @@ export class SpeakerIdentificationEngine {
301
333
  const idx = merged.indexOf(conflict);
302
334
  merged[idx] = incoming;
303
335
  this.log(`[engine:${backend.name}] windowed claim REPLACED weaker incumbent for ${key}`);
336
+ if (this.isPublishable(incoming))
337
+ addedPublished = true;
304
338
  }
305
339
  else {
306
340
  this.log(`[engine:${backend.name}] windowed claim REJECTED (overlaps stronger incumbent for ${key})`);
@@ -308,9 +342,12 @@ export class SpeakerIdentificationEngine {
308
342
  continue;
309
343
  }
310
344
  merged.push(incoming);
345
+ if (this.isPublishable(incoming))
346
+ addedPublished = true;
311
347
  }
312
348
  this.windowedMapping.set(key, merged);
313
- this._hasMappingChanged = true;
349
+ if (addedPublished)
350
+ this._hasMappingChanged = true;
314
351
  }
315
352
  }
316
353
  for (const merge of decided.merges ?? []) {
@@ -319,6 +356,7 @@ export class SpeakerIdentificationEngine {
319
356
  for (const [key, entry] of Object.entries(decided.mapping)) {
320
357
  if (!entry)
321
358
  continue;
359
+ proposedFp.set(key, `${entry.speakerId}|${entry.label}`);
322
360
  if (!this.beatsIncumbent(key, entry)) {
323
361
  const incumbent = this.currentMapping.get(key);
324
362
  this.log(`[engine:${backend.name}] ${key} → ${entry.label} REJECTED (${entry.strength} vs incumbent ${incumbent?.strength ?? 'none'})`);
@@ -330,11 +368,42 @@ export class SpeakerIdentificationEngine {
330
368
  // above (beatsIncumbent) is the only gate.
331
369
  const existing = this.currentMapping.get(key);
332
370
  const enriched = this.enrich(entry);
371
+ const before = this.publishedProjection(existing);
333
372
  this.currentMapping.set(key, { ...enriched, source: backend.name });
334
- this._hasMappingChanged = true;
373
+ const after = this.publishedProjection(this.currentMapping.get(key));
374
+ if (before !== after)
375
+ this._hasMappingChanged = true;
335
376
  this.log(`[engine:${backend.name}] ${key} → ${enriched.label} [${enriched.strength}]${enriched.evidence ? ` (${enriched.evidence})` : ''}${existing ? ' [remapped]' : ''}`);
336
377
  }
337
378
  }
379
+ // Corroboration: a flat weak claim whose identity is re-proposed (or
380
+ // simply left untouched) identically run after run is promoted — its
381
+ // evidence stays consistent while the NVS enriches. A backend proposing
382
+ // a different identity for the key resets the streak.
383
+ const n = this.args.corroborationRuns ?? 0;
384
+ if (n >= 2) {
385
+ for (const [key, entry] of this.currentMapping) {
386
+ if (entry.strength !== 'weak')
387
+ continue;
388
+ const fp = proposedFp.get(key);
389
+ if (fp === undefined)
390
+ continue; // no proposal this run → streak untouched
391
+ if (fp !== this.claimStreakFp.get(key)) {
392
+ this.claimStreakFp.set(key, fp);
393
+ this.claimStreakRuns.set(key, 1);
394
+ continue;
395
+ }
396
+ const runs = (this.claimStreakRuns.get(key) ?? 0) + 1;
397
+ this.claimStreakRuns.set(key, runs);
398
+ if (runs >= n) {
399
+ entry.strength = 'strong';
400
+ this._hasMappingChanged = true;
401
+ this.log(`[engine:corroboration] ${key} → ${entry.label} PROMOTED (stable over ${runs} runs)`);
402
+ this.claimStreakFp.set(key, fp);
403
+ this.claimStreakRuns.set(key, runs);
404
+ }
405
+ }
406
+ }
338
407
  }
339
408
  /** All diarization keys seen so far hold a 'strong' claim. Since a strong
340
409
  * incumbent is never dethroned (see beatsIncumbent), no backend can change
@@ -445,13 +514,22 @@ export class SpeakerIdentificationEngine {
445
514
  // A claim held on the loser moves to the canonical key (per-key
446
515
  // arbitration still applies: it must beat the canonical incumbent).
447
516
  const loserClaim = this.currentMapping.get(loser);
517
+ const before = [
518
+ this.publishedProjection(this.currentMapping.get(canonical)),
519
+ this.publishedProjection(loserClaim),
520
+ ];
448
521
  if (loserClaim) {
449
522
  this.currentMapping.delete(loser);
450
523
  if (this.beatsIncumbent(canonical, loserClaim)) {
451
524
  this.currentMapping.set(canonical, loserClaim);
452
525
  }
453
526
  }
454
- this._hasMappingChanged = true;
527
+ const after = [
528
+ this.publishedProjection(this.currentMapping.get(canonical)),
529
+ this.publishedProjection(this.currentMapping.get(loser)),
530
+ ];
531
+ if (before.join() !== after.join())
532
+ this._hasMappingChanged = true;
455
533
  this.log(`[engine:${backendName}] merge ${source} → ${canonical}${merge.kind ? ` [${merge.kind}]` : ''}${merge.evidence ? ` (${merge.evidence})` : ''}`);
456
534
  }
457
535
  /** Sole arbitration rule (per-key only — many-to-one allowed across keys):
@@ -1,5 +1,6 @@
1
1
  import { SpeakerIdentificationEngine, type IdentificationContext } from './engine.js';
2
2
  import type { MaybePromise } from './types.js';
3
+ import type { ClaimStrength } from './types.js';
3
4
  import { type DecisionBackendKind } from './backends/selection.js';
4
5
  import type { DeterministicBackendOptions } from './backends/deterministic.js';
5
6
  import type { LlmBackendOptions } from './backends/llm.js';
@@ -16,6 +17,13 @@ export type CreateSpeakerIdentifierArgs = {
16
17
  * No default patterns: business layers inject their locale pack
17
18
  * (or leave it unset to disable floor-announcement matching). */
18
19
  deterministic?: DeterministicBackendOptions;
20
+ /** Minimum claim strength exposed to labels and downstream pushes.
21
+ * Below-threshold claims stay internal (later runs see them via
22
+ * knownMapping and can upgrade them). Default 'weak' (no filtering). */
23
+ minPublishStrength?: ClaimStrength;
24
+ /** Corroboration runs (weak→strong promotion streak). Below 2 disables.
25
+ * Default 0 (off). */
26
+ corroborationRuns?: number;
19
27
  /** Decision layer(s). Default 'cascade' (deterministic → llm); 'deterministic' keeps tests offline. */
20
28
  backendKind?: DecisionBackendKind;
21
29
  debug?: boolean;
@@ -26,6 +26,8 @@ export function createSpeakerIdentifier(args) {
26
26
  fetchContext: async () => args.fetchContext(),
27
27
  triggerMs: args.triggerMs,
28
28
  timeOffsetMs: args.timeOffsetMs,
29
+ minPublishStrength: args.minPublishStrength,
30
+ corroborationRuns: args.corroborationRuns,
29
31
  debug,
30
32
  });
31
33
  }
@@ -1,6 +1,7 @@
1
1
  import type { IdentificationContext } from '../../core/identification/engine.js';
2
2
  import type { SpeakerIdentificationEngine } from '../../core/identification/engine.js';
3
3
  import type { MaybePromise } from '../../core/identification/types.js';
4
+ import type { ClaimStrength } from '../../core/identification/types.js';
4
5
  import type { DecisionBackendKind } from '../../core/identification/backends/selection.js';
5
6
  import type { AgendaChapter } from '../nvs/types.js';
6
7
  export type CreateSpeakerIdentifierArgs = {
@@ -18,6 +19,16 @@ export type CreateSpeakerIdentifierArgs = {
18
19
  timeOffsetMs?: number;
19
20
  /** LLM transcript selection: 'last' (rolling live) or 'all' (batch chunks). Default 'last'. */
20
21
  llmChunkMode?: 'last' | 'all';
22
+ /** Minimum claim strength exposed to labels and downstream pushes.
23
+ * Below-threshold claims stay internal (later runs see them and can
24
+ * upgrade them). Default 'weak' (no filtering). */
25
+ minPublishStrength?: ClaimStrength;
26
+ /** Rolling LLM transcript window in ms (live 'last' mode). Overrides
27
+ * IDENTIFICATION_LLM_WINDOW_MS (default 300s). */
28
+ llmWindowMs?: number;
29
+ /** Corroboration runs (weak→strong promotion streak). Below 2 disables.
30
+ * Default 0 (off). */
31
+ corroborationRuns?: number;
21
32
  /** Decision layer(s). Default 'cascade' (deterministic → llm); 'deterministic' keeps tests offline. */
22
33
  backendKind?: DecisionBackendKind;
23
34
  debug?: boolean;
@@ -69,8 +69,11 @@ export function createSpeakerIdentifier(args) {
69
69
  systemPrompt: parliamentSystemPromptFr,
70
70
  buildUserPrompt: buildParliamentUserPrompt,
71
71
  chunkMode: args.llmChunkMode,
72
+ windowMs: args.llmWindowMs,
72
73
  },
73
74
  deterministic: { announcementPatterns: announcementPatternsFr },
75
+ minPublishStrength: args.minPublishStrength,
76
+ corroborationRuns: args.corroborationRuns,
74
77
  debug,
75
78
  });
76
79
  }
@@ -4,6 +4,6 @@ import type { LlmPromptParts } from '../../core/identification/backends/prompt.j
4
4
  * Injected by the parliament adapter via `LlmBackendOptions`; the core
5
5
  * default stays domain-agnostic.
6
6
  */
7
- export declare const parliamentSystemPromptFr = "Tu analyses la transcription en direct d'une s\u00E9ance parlementaire fran\u00E7aise.\nLes locuteurs sont \u00E9tiquet\u00E9s A, B, C... par un syst\u00E8me de diarisation automatique.\n\nTa t\u00E2che : d\u00E9terminer quel participant de la liste officielle correspond \u00E0 chaque \u00E9tiquette de locuteur, en utilisant le texte transcrit comme preuve.\n\nR\u00C8GLES IMPORTANTES:\n1. Si une liste officielle est fournie, attribue EN PRIORIT\u00C9 des identifiants de cette liste : si la personne y figure, copie son \"id\" EXACTEMENT depuis la liste.\nN'utilise un NOM LIBRE (champ \"name\", avec \"id\": null) que si l'intervenant est identifiable avec certitude mais ABSENT de la liste (NVS partiel : intervenant annonc\u00E9 \u00E0 l'ordre du jour ou nomm\u00E9 dans la transcription mais non list\u00E9). Un nom libre doit \u00EAtre attest\u00E9 mot pour mot (transcription, ordre du jour) et appuy\u00E9 par une preuve citable : JAMAIS un nom devin\u00E9 ou d\u00E9duit indirectement.\nSi la liste officielle est VIDE ou ABSENTE (d\u00E9but de s\u00E9ance, NVS non encore renseign\u00E9), attribue directement le NOM R\u00C9EL de l'intervenant (champ \"name\", avec \"id\": null), d\u00E9duit de la transcription et de l'ordre du jour (octroi de parole par nom, ouverture de s\u00E9ance, auto-pr\u00E9sentation) \u2014 avec la m\u00EAme exigence de certitude absolue.\n\n2. Toute identification doit reposer sur une PREUVE CITABLE : recopie dans \"evidence\" l'indice exact entendu ou lu (octroi de parole, ouverture de s\u00E9ance, th\u00E8me + ordre du jour). Une r\u00E9ponse sans preuve v\u00E9rifiable sera rejet\u00E9e. En cas de doute, NE RENDS RIEN pour ce locuteur : un orateur laiss\u00E9 sans identit\u00E9 est acceptable, une identit\u00E9 erron\u00E9e ne l'est pas.\n\n2 bis. Les listes officielles contiennent parfois des ENTR\u00C9ES DE R\u00D4LE G\u00C9N\u00C9RIQUE (nom \u00AB pr\u00E9sident de s\u00E9ance \u00BB, \u00AB rapporteur \u00BB, \u00AB intervenant \u00BB, sans nom de personne). N'attribue JAMAIS l'identifiant d'une telle entr\u00E9e : c'est un r\u00F4le, pas une personne. Si la liste contient aussi une personne r\u00E9elle qui peut l'assumer (m\u00EAme fonction, m\u00EAme intervention), r\u00E9sous le r\u00F4le vers cette personne r\u00E9elle avec sa preuve citable ; sinon abstains-toi. De m\u00EAme, un locuteur provisoirement \u00E9tiquet\u00E9 par un r\u00F4le g\u00E9n\u00E9rique doit \u00EAtre r\u00E9-identifi\u00E9 vers la personne r\u00E9elle d\u00E8s que le texte le permet (ouverture de s\u00E9ance, adresses \u00AB Madame la pr\u00E9sidente / Monsieur le pr\u00E9sident \u00BB, octroi de parole par un nom coh\u00E9rent avec la fonction).\n\n3. La diarisation peut couper un m\u00EAme orateur en plusieurs \u00E9tiquettes (ex. A et C sont la m\u00EAme personne). Plusieurs cl\u00E9s PEUVENT donc pointer vers le m\u00EAme identifiant : mappe chaque \u00E9tiquette ind\u00E9pendamment.\n\n4. Inversement, une M\u00CAME \u00E9tiquette peut couvrir PLUSIEURS personnes (la diarisation regroupe parfois deux voix proches, typiquement en questions au gouvernement). Si le texte montre deux prises de parole distinctes sous la m\u00EAme cl\u00E9 (annonces diff\u00E9rentes, sujets disjoints), rends une entr\u00E9e FEN\u00CATR\u00C9E par intervalle (champ \"claims\") au lieu d'une identit\u00E9 unique.\n\n5. Abstention explicite : pour un intervalle que tu ne peux pas identifier, rends une entr\u00E9e \"claims\" avec \"confidence\": \"abstain\" et \"id\": null. Cela vaut mieux qu'un nom devin\u00E9.\n\n6. Fusions : une section \"Paires suspectes\" peut lister des cl\u00E9s adjacentes possiblement issues du m\u00EAme tour de parole (ex. \"B \u2192 A ?\"). Si et seulement si le texte prouve manifestement la continuit\u00E9 (phrase coup\u00E9e, m\u00EAme annonce, m\u00EAme orateur poursuivant son propos), propose une fusion. Ne propose JAMAIS de fusion hors de cette liste. En cas de doute, ne propose rien.\n\nTypes de preuves \u00E0 rechercher dans la transcription :\n- Octroi de parole : \"La parole est \u00E0 M./Mme [NOM]\", \"Je donne/c\u00E8de/passe la parole \u00E0...\", \"[NOM], vous avez la parole\"\n- Octroi de parole par R\u00D4LE : \"La parole est \u00E0 M. le rapporteur\", \"M. le pr\u00E9sident, vous avez la parole\", \"j'appelle Mme la rapporteure\" \u2192 r\u00E9sous le r\u00F4le via les \"R\u00F4les\" de la liste officielle des participants. Un seul intervenant porte ce r\u00F4le \u2192 confidence \"high\" ; plusieurs candidats \u2192 confidence \"low\" seulement si un autre indice (ordre du jour, contexte) les d\u00E9partage, sinon abstention. Attention aux variantes entendues (rapporteure, la pr\u00E9sidente, pr\u00E9sident de la commission vs pr\u00E9sident de s\u00E9ance).\n- Auto-r\u00E9f\u00E9rence de r\u00F4le : \"En tant que rapporteur/rapporteure...\", \"En ma qualit\u00E9 de...\"\n- Adresse au pr\u00E9sident : \"Madame/Monsieur la/le pr\u00E9sident(e)...\" \u2192 permet d'identifier le pr\u00E9sident par d\u00E9duction\n- Ouverture de s\u00E9ance : \"La s\u00E9ance est ouverte\", \"L'ordre du jour appelle...\" \u2192 ce locuteur est le/la pr\u00E9sident(e)\n- Contexte de la s\u00E9ance : le titre peut confirmer l'identit\u00E9 d'un auditeur\n- Ordre du jour : si une s\u00E9quence d'intervention pr\u00E9vue est fournie, utilise-la comme signal d'appui pour confirmer l'identit\u00E9 d'un locuteur lorsque la s\u00E9quence de prise de parole correspond. Attention : des interventions hors-ordre sont possibles, ne l'utilise jamais seul sans autre indice.\n\nR\u00E9ponds UNIQUEMENT avec ce type de JSON :\n{\n \"claims\": [\n { \"key\": \"A\", \"from\": \"00:01:23\", \"to\": \"00:03:10\", \"id\": 18960, \"confidence\": \"high\", \"kind\": \"ouverture de s\u00E9ance\", \"evidence\": \"le locuteur d\u00E9clare la s\u00E9ance ouverte\" },\n { \"key\": \"H\", \"from\": \"00:56:32\", \"to\": \"00:58:25\", \"id\": 7, \"confidence\": \"high\", \"kind\": \"octroi de parole\", \"evidence\": \"le pr\u00E9sident annonce \u00AB la parole est \u00E0 monsieur X \u00BB juste avant\" },\n { \"key\": \"B\", \"from\": \"01:12:00\", \"to\": \"01:13:40\", \"id\": null, \"confidence\": \"abstain\", \"kind\": \"\", \"evidence\": \"\" }\n ],\n \"mapping\": [],\n \"merges\": [\n { \"key\": \"B\", \"into\": \"A\", \"kind\": \"continuit\u00E9 syntaxique\", \"evidence\": \"la phrase de A se poursuit en B\" }\n ]\n}\n- \"key\" : l'\u00E9tiquette EXACTE de la transcription.\n- \"from\"/\"to\" : bornes temporelles du tour de parole, COPI\u00C9ES depuis les horodatages [HH:MM:SS] de la transcription. Utilise \"claims\" quand une cl\u00E9 couvre plusieurs personnes ; sinon une seule entr\u00E9e couvrant toute la cl\u00E9.\n- \"id\" : la valeur \"ID\" de l'intervenant, copi\u00E9e EXACTEMENT depuis la liste ; null avec \"abstain\", ou null avec \"name\": \"Nom attest\u00E9\" quand l'intervenant est absent de la liste ou que la liste est vide/absente.\n- \"confidence\" : \"high\" si la preuve est citable mot pour mot, \"low\" si elle est indirecte (th\u00E8me + ordre du jour), \"abstain\" si aucune.\n- \"mapping\" (format historique, une identit\u00E9 par cl\u00E9 enti\u00E8re) reste accept\u00E9 si tu pr\u00E9f\u00E8res ne pas segmenter.\n- Le champ \"kind\" doit nommer le type de preuve utilis\u00E9 (ex. \"octroi de parole\", \"ouverture de s\u00E9ance\", \"auto-r\u00E9f\u00E9rence de r\u00F4le\", \"adresse au pr\u00E9sident\", \"ordre du jour\").";
7
+ export declare const parliamentSystemPromptFr = "Tu analyses la transcription en direct d'une s\u00E9ance parlementaire fran\u00E7aise.\nLes locuteurs sont \u00E9tiquet\u00E9s A, B, C... par un syst\u00E8me de diarisation automatique.\n\nTa t\u00E2che : d\u00E9terminer quel participant de la liste officielle correspond \u00E0 chaque \u00E9tiquette de locuteur, en utilisant le texte transcrit comme preuve.\n\nR\u00C8GLES IMPORTANTES:\n1. Si une liste officielle est fournie, attribue EN PRIORIT\u00C9 des identifiants de cette liste : si la personne y figure, copie son \"id\" EXACTEMENT depuis la liste.\nN'utilise un NOM LIBRE (champ \"name\", avec \"id\": null) que si l'intervenant est identifiable avec certitude mais ABSENT de la liste (NVS partiel : intervenant annonc\u00E9 \u00E0 l'ordre du jour ou nomm\u00E9 dans la transcription mais non list\u00E9). Un nom libre doit \u00EAtre attest\u00E9 mot pour mot (transcription, ordre du jour) et appuy\u00E9 par une preuve citable : JAMAIS un nom devin\u00E9 ou d\u00E9duit indirectement.\nSi la liste officielle est VIDE ou ABSENTE (d\u00E9but de s\u00E9ance, NVS non encore renseign\u00E9), attribue directement le NOM R\u00C9EL de l'intervenant (champ \"name\", avec \"id\": null), d\u00E9duit de la transcription et de l'ordre du jour (octroi de parole par nom, ouverture de s\u00E9ance, auto-pr\u00E9sentation) \u2014 avec la m\u00EAme exigence de certitude absolue.\n\n2. Toute identification doit reposer sur une PREUVE CITABLE : recopie dans \"evidence\" l'indice exact entendu ou lu (octroi de parole, ouverture de s\u00E9ance, th\u00E8me + ordre du jour). Une r\u00E9ponse sans preuve v\u00E9rifiable sera rejet\u00E9e. En cas de doute, NE RENDS RIEN pour ce locuteur : un orateur laiss\u00E9 sans identit\u00E9 est acceptable, une identit\u00E9 erron\u00E9e ne l'est pas.\n\n2 bis. Les listes officielles contiennent parfois des ENTR\u00C9ES DE R\u00D4LE G\u00C9N\u00C9RIQUE (nom \u00AB pr\u00E9sident de s\u00E9ance \u00BB, \u00AB rapporteur \u00BB, \u00AB intervenant \u00BB, sans nom de personne). N'attribue JAMAIS l'identifiant d'une telle entr\u00E9e : c'est un r\u00F4le, pas une personne. Si la liste contient aussi une personne r\u00E9elle qui peut l'assumer (m\u00EAme fonction, m\u00EAme intervention), r\u00E9sous le r\u00F4le vers cette personne r\u00E9elle avec sa preuve citable ; sinon abstains-toi. De m\u00EAme, un locuteur provisoirement \u00E9tiquet\u00E9 par un r\u00F4le g\u00E9n\u00E9rique doit \u00EAtre r\u00E9-identifi\u00E9 vers la personne r\u00E9elle d\u00E8s que le texte le permet (ouverture de s\u00E9ance, adresses \u00AB Madame la pr\u00E9sidente / Monsieur le pr\u00E9sident \u00BB, octroi de parole par un nom coh\u00E9rent avec la fonction).\n\n3. La diarisation peut couper un m\u00EAme orateur en plusieurs \u00E9tiquettes (ex. A et C sont la m\u00EAme personne). Plusieurs cl\u00E9s PEUVENT donc pointer vers le m\u00EAme identifiant : mappe chaque \u00E9tiquette ind\u00E9pendamment.\n\n4. Inversement, une M\u00CAME \u00E9tiquette peut couvrir PLUSIEURS personnes (la diarisation regroupe parfois deux voix proches, typiquement en questions au gouvernement). Si le texte montre deux prises de parole distinctes sous la m\u00EAme cl\u00E9 (annonces diff\u00E9rentes, sujets disjoints), rends une entr\u00E9e FEN\u00CATR\u00C9E par intervalle (champ \"claims\") au lieu d'une identit\u00E9 unique.\n\n4 bis. ATTENTION \u2014 lier une cl\u00E9 ENTI\u00C8RE (section \"mapping\") engage toute la s\u00E9ance pour cette \u00E9tiquette. Un discours d'ouverture ou de bienvenue (\"je vous souhaite la bienvenue\", \"je vous remercie\", annonce des invit\u00E9s) ne prouve que le locuteur au moment o\u00F9 il parle : il ne permet JAMAIS \u00E0 lui seul de lier la cl\u00E9 ENTI\u00C8RE \u2014 la m\u00EAme cl\u00E9 couvre ensuite souvent d'autres intervenants (discours d'ouverture du pr\u00E9sident PUIS expos\u00E9 d'un invit\u00E9). En cas de doute sur l'homog\u00E9n\u00E9it\u00E9 d'une cl\u00E9, rends des entr\u00E9es FEN\u00CATR\u00C9ES (\"claims\") avec des bornes exactes, jamais \"mapping\".\n\n5. Abstention explicite : pour un intervalle que tu ne peux pas identifier, rends une entr\u00E9e \"claims\" avec \"confidence\": \"abstain\" et \"id\": null. Cela vaut mieux qu'un nom devin\u00E9.\n\n6. Fusions : une section \"Paires suspectes\" peut lister des cl\u00E9s adjacentes possiblement issues du m\u00EAme tour de parole (ex. \"B \u2192 A ?\"). Si et seulement si le texte prouve manifestement la continuit\u00E9 (phrase coup\u00E9e, m\u00EAme annonce, m\u00EAme orateur poursuivant son propos), propose une fusion. Ne propose JAMAIS de fusion hors de cette liste. En cas de doute, ne propose rien.\n\nTypes de preuves \u00E0 rechercher dans la transcription :\n- Octroi de parole : \"La parole est \u00E0 M./Mme [NOM]\", \"Je donne/c\u00E8de/passe la parole \u00E0...\", \"[NOM], vous avez la parole\"\n- Octroi de parole par R\u00D4LE : \"La parole est \u00E0 M. le rapporteur\", \"M. le pr\u00E9sident, vous avez la parole\", \"j'appelle Mme la rapporteure\" \u2192 r\u00E9sous le r\u00F4le via les \"R\u00F4les\" de la liste officielle des participants. ATTENTION : la personne list\u00E9e sous ce r\u00F4le peut si\u00E9ger dans une AUTRE commission r\u00E9unie en parall\u00E8le \u2014 le r\u00F4le SEUL ne suffit jamais. R\u00F4le seul (nom non cit\u00E9 dans la m\u00EAme fen\u00EAtre) \u2192 confidence \"low\" maximum : la r\u00E9solution restera provisoire jusqu'\u00E0 corroboration (nom cit\u00E9, fen\u00EAtres temporelles officielles). \"high\" exige le NOM cit\u00E9 en plus du r\u00F4le. Plusieurs candidats \u2192 abstention sauf indice suppl\u00E9mentaire qui d\u00E9partage (ordre du jour, contexte, nom). Attention aux variantes entendues (rapporteure, la pr\u00E9sidente, pr\u00E9sident de la commission vs pr\u00E9sident de s\u00E9ance).\n- Auto-r\u00E9f\u00E9rence de r\u00F4le : \"En tant que rapporteur/rapporteure...\", \"En ma qualit\u00E9 de...\"\n- Adresse au pr\u00E9sident : \"Madame/Monsieur la/le pr\u00E9sident(e)...\" \u2192 permet d'identifier le pr\u00E9sident par d\u00E9duction\n- Ouverture de s\u00E9ance : \"La s\u00E9ance est ouverte\", \"L'ordre du jour appelle...\" \u2192 ce locuteur est le/la pr\u00E9sident(e)\n- Contexte de la s\u00E9ance : le titre peut confirmer l'identit\u00E9 d'un auditeur\n- Ordre du jour : si une s\u00E9quence d'intervention pr\u00E9vue est fournie, utilise-la comme signal d'appui pour confirmer l'identit\u00E9 d'un locuteur lorsque la s\u00E9quence de prise de parole correspond. Attention : des interventions hors-ordre sont possibles, ne l'utilise jamais seul sans autre indice.\n\nR\u00E9ponds UNIQUEMENT avec ce type de JSON :\n{\n \"claims\": [\n { \"key\": \"A\", \"from\": \"00:01:23\", \"to\": \"00:03:10\", \"id\": 18960, \"confidence\": \"high\", \"kind\": \"ouverture de s\u00E9ance\", \"evidence\": \"le locuteur d\u00E9clare la s\u00E9ance ouverte\" },\n { \"key\": \"H\", \"from\": \"00:56:32\", \"to\": \"00:58:25\", \"id\": 7, \"confidence\": \"high\", \"kind\": \"octroi de parole\", \"evidence\": \"le pr\u00E9sident annonce \u00AB la parole est \u00E0 monsieur X \u00BB juste avant\" },\n { \"key\": \"B\", \"from\": \"01:12:00\", \"to\": \"01:13:40\", \"id\": null, \"confidence\": \"abstain\", \"kind\": \"\", \"evidence\": \"\" }\n ],\n \"mapping\": [],\n \"merges\": [\n { \"key\": \"B\", \"into\": \"A\", \"kind\": \"continuit\u00E9 syntaxique\", \"evidence\": \"la phrase de A se poursuit en B\" }\n ]\n}\n- \"key\" : l'\u00E9tiquette EXACTE de la transcription.\n- \"from\"/\"to\" : bornes temporelles du tour de parole, COPI\u00C9ES depuis les horodatages [HH:MM:SS] de la transcription. Utilise \"claims\" quand une cl\u00E9 couvre plusieurs personnes ; sinon une seule entr\u00E9e couvrant toute la cl\u00E9.\n- \"id\" : la valeur \"ID\" de l'intervenant, copi\u00E9e EXACTEMENT depuis la liste ; null avec \"abstain\", ou null avec \"name\": \"Nom attest\u00E9\" quand l'intervenant est absent de la liste ou que la liste est vide/absente.\n- \"confidence\" : \"high\" si la preuve est citable mot pour mot, \"low\" si elle est indirecte (th\u00E8me + ordre du jour), \"abstain\" si aucune.\n- \"mapping\" (format historique, une identit\u00E9 par cl\u00E9 enti\u00E8re) reste accept\u00E9 UNIQUEMENT avec \"confidence\": \"high\" explicite ; sans \"confidence\": \"high\" l'entr\u00E9e est trait\u00E9e comme PROVISOIRE (non publi\u00E9e). Voir r\u00E8gle 4 bis avant de lier une cl\u00E9 enti\u00E8re.\n- Le champ \"kind\" doit nommer le type de preuve utilis\u00E9 (ex. \"octroi de parole\", \"ouverture de s\u00E9ance\", \"auto-r\u00E9f\u00E9rence de r\u00F4le\", \"adresse au pr\u00E9sident\", \"ordre du jour\").";
8
8
  /** Parliament user prompt: "séance / liste officielle / ordre du jour" wording. */
9
9
  export declare function buildParliamentUserPrompt(parts: LlmPromptParts): string;
@@ -21,13 +21,15 @@ Si la liste officielle est VIDE ou ABSENTE (début de séance, NVS non encore re
21
21
 
22
22
  4. Inversement, une MÊME étiquette peut couvrir PLUSIEURS personnes (la diarisation regroupe parfois deux voix proches, typiquement en questions au gouvernement). Si le texte montre deux prises de parole distinctes sous la même clé (annonces différentes, sujets disjoints), rends une entrée FENÊTRÉE par intervalle (champ "claims") au lieu d'une identité unique.
23
23
 
24
+ 4 bis. ATTENTION — lier une clé ENTIÈRE (section "mapping") engage toute la séance pour cette étiquette. Un discours d'ouverture ou de bienvenue ("je vous souhaite la bienvenue", "je vous remercie", annonce des invités) ne prouve que le locuteur au moment où il parle : il ne permet JAMAIS à lui seul de lier la clé ENTIÈRE — la même clé couvre ensuite souvent d'autres intervenants (discours d'ouverture du président PUIS exposé d'un invité). En cas de doute sur l'homogénéité d'une clé, rends des entrées FENÊTRÉES ("claims") avec des bornes exactes, jamais "mapping".
25
+
24
26
  5. Abstention explicite : pour un intervalle que tu ne peux pas identifier, rends une entrée "claims" avec "confidence": "abstain" et "id": null. Cela vaut mieux qu'un nom deviné.
25
27
 
26
28
  6. Fusions : une section "Paires suspectes" peut lister des clés adjacentes possiblement issues du même tour de parole (ex. "B → A ?"). Si et seulement si le texte prouve manifestement la continuité (phrase coupée, même annonce, même orateur poursuivant son propos), propose une fusion. Ne propose JAMAIS de fusion hors de cette liste. En cas de doute, ne propose rien.
27
29
 
28
30
  Types de preuves à rechercher dans la transcription :
29
31
  - Octroi de parole : "La parole est à M./Mme [NOM]", "Je donne/cède/passe la parole à...", "[NOM], vous avez la parole"
30
- - Octroi de parole par RÔLE : "La parole est à M. le rapporteur", "M. le président, vous avez la parole", "j'appelle Mme la rapporteure" → résous le rôle via les "Rôles" de la liste officielle des participants. Un seul intervenant porte ce rôle → confidence "high" ; plusieurs candidats → confidence "low" seulement si un autre indice (ordre du jour, contexte) les départage, sinon abstention. Attention aux variantes entendues (rapporteure, la présidente, président de la commission vs président de séance).
32
+ - Octroi de parole par RÔLE : "La parole est à M. le rapporteur", "M. le président, vous avez la parole", "j'appelle Mme la rapporteure" → résous le rôle via les "Rôles" de la liste officielle des participants. ATTENTION : la personne listée sous ce rôle peut siéger dans une AUTRE commission réunie en parallèle — le rôle SEUL ne suffit jamais. Rôle seul (nom non cité dans la même fenêtre) → confidence "low" maximum : la résolution restera provisoire jusqu'à corroboration (nom cité, fenêtres temporelles officielles). "high" exige le NOM cité en plus du rôle. Plusieurs candidats → abstention sauf indice supplémentaire qui départage (ordre du jour, contexte, nom). Attention aux variantes entendues (rapporteure, la présidente, président de la commission vs président de séance).
31
33
  - Auto-référence de rôle : "En tant que rapporteur/rapporteure...", "En ma qualité de..."
32
34
  - Adresse au président : "Madame/Monsieur la/le président(e)..." → permet d'identifier le président par déduction
33
35
  - Ouverture de séance : "La séance est ouverte", "L'ordre du jour appelle..." → ce locuteur est le/la président(e)
@@ -50,7 +52,7 @@ Réponds UNIQUEMENT avec ce type de JSON :
50
52
  - "from"/"to" : bornes temporelles du tour de parole, COPIÉES depuis les horodatages [HH:MM:SS] de la transcription. Utilise "claims" quand une clé couvre plusieurs personnes ; sinon une seule entrée couvrant toute la clé.
51
53
  - "id" : la valeur "ID" de l'intervenant, copiée EXACTEMENT depuis la liste ; null avec "abstain", ou null avec "name": "Nom attesté" quand l'intervenant est absent de la liste ou que la liste est vide/absente.
52
54
  - "confidence" : "high" si la preuve est citable mot pour mot, "low" si elle est indirecte (thème + ordre du jour), "abstain" si aucune.
53
- - "mapping" (format historique, une identité par clé entière) reste accepté si tu préfères ne pas segmenter.
55
+ - "mapping" (format historique, une identité par clé entière) reste accepté UNIQUEMENT avec "confidence": "high" explicite ; sans "confidence": "high" l'entrée est traitée comme PROVISOIRE (non publiée). Voir règle 4 bis avant de lier une clé entière.
54
56
  - Le champ "kind" doit nommer le type de preuve utilisé (ex. "octroi de parole", "ouverture de séance", "auto-référence de rôle", "adresse au président", "ordre du jour").`;
55
57
  /** Parliament user prompt: "séance / liste officielle / ordre du jour" wording. */
56
58
  export function buildParliamentUserPrompt(parts) {
@@ -77,6 +79,6 @@ Réponds uniquement avec ce type de JSON :
77
79
  - "from"/"to" : bornes COPIÉES depuis les horodatages [HH:MM:SS] de la transcription.
78
80
  - "id" : la valeur "ID" de l'intervenant, copiée EXACTEMENT depuis la liste ; null avec "abstain", ou null avec "name": "Nom attesté" quand l'intervenant est absent de la liste ou que la liste est vide/absente.
79
81
  - "confidence" : "high" si la preuve est citable mot pour mot, "low" si elle est indirecte, "abstain" si aucune.
80
- - "mapping" (format historique) reste accepté.
82
+ - "mapping" (format historique) reste accepté UNIQUEMENT avec "confidence": "high" explicite ; sans "confidence": "high" l'entrée est traitée comme PROVISOIRE (non publiée).
81
83
  - Le champ "kind" doit nommer le type de preuve utilisé (ex. "octroi de parole", "ouverture de séance", "auto-référence de rôle", "ordre du jour").`;
82
84
  }
@@ -149,6 +149,9 @@ export async function runForeverPush(args) {
149
149
  finalPlayer: () => fetchText(livePlayerNvsUrl, { headers: PARLIAMENT_HEADERS }),
150
150
  sessionTitle,
151
151
  triggerMs: 30_000,
152
+ minPublishStrength: ENV.IDENTIFICATION_MIN_PUBLISH_STRENGTH,
153
+ llmWindowMs: ENV.IDENTIFICATION_LLM_WINDOW_MS,
154
+ corroborationRuns: ENV.IDENTIFICATION_CORROBORATION_RUNS,
152
155
  debug,
153
156
  })
154
157
  : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tricoteuses/transcription-videos",
3
- "version": "1.7.2",
3
+ "version": "1.7.4",
4
4
  "description": "Permet d'obtenir la transcription des vidéos de l'assemblée/sénat en fournissant un lien vidéo m3u8 en entrée",
5
5
  "keywords": [
6
6
  "Assemblée nationale",