@tricoteuses/senat 3.3.1 → 3.3.2

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.
@@ -16,6 +16,15 @@ export declare function dice(a: string, b: string): number;
16
16
  export declare function coverage(reference?: string | null, candidate?: string | null): number;
17
17
  export declare function diceFiltered(a?: string | null, b?: string | null): number;
18
18
  export declare function similarityScore(a?: string | null, b?: string | null): number;
19
+ /**
20
+ * Org gate used by the video matching pipeline.
21
+ * sameOrg=true when both organes map to the same known key, or when the strings
22
+ * are (near) identical even if getOrgKey leaves them unmapped ("autre").
23
+ */
24
+ export declare function computeOrgMatch(agendaOrgane: string, videoOrganes: string[]): {
25
+ sameOrg: boolean;
26
+ bestDice: number;
27
+ };
19
28
  export declare function normalize(s?: string | null): string;
20
29
  export declare function normalizeSalle(s?: string | null): string | null;
21
30
  export declare function scoreVideo(agenda: Reunion, agendaTs: number | null, sameOrg: boolean, w: VideoScoreWeights, videoTitle?: string, videoEpoch?: number, videoOrganes?: string[], timeAmbigious?: boolean, salle?: string, chapterTitles?: L1Chapter[]): {
@@ -182,10 +182,35 @@ export function diceFiltered(a, b) {
182
182
  return (2 * inter) / (A.size + B.size);
183
183
  }
184
184
  export function similarityScore(a, b) {
185
- const cov = coverage(a, b);
185
+ // Symmetric coverage: a short video title entirely contained in a long agenda
186
+ // title must score high, whichever direction the extra words are.
187
+ const cov = Math.max(coverage(a, b), coverage(b, a));
186
188
  const d = diceFiltered(a, b);
187
189
  return 0.7 * cov + 0.3 * d;
188
190
  }
191
+ /**
192
+ * Org gate used by the video matching pipeline.
193
+ * sameOrg=true when both organes map to the same known key, or when the strings
194
+ * are (near) identical even if getOrgKey leaves them unmapped ("autre").
195
+ */
196
+ export function computeOrgMatch(agendaOrgane, videoOrganes) {
197
+ const agendaOrgNorm = normalize(agendaOrgane);
198
+ const agendaKey = getOrgKey(agendaOrgNorm);
199
+ let bestDice = 0;
200
+ let hasSameKey = false;
201
+ for (const vo of videoOrganes) {
202
+ const videoOrgNorm = normalize(vo);
203
+ const videoKey = getOrgKey(videoOrgNorm);
204
+ const d = dice(agendaOrgNorm, videoOrgNorm);
205
+ if (videoKey === agendaKey && videoKey !== "autre")
206
+ hasSameKey = true;
207
+ if (d > bestDice)
208
+ bestDice = d;
209
+ }
210
+ if (bestDice >= 0.999)
211
+ hasSameKey = true;
212
+ return { sameOrg: hasSameKey, bestDice };
213
+ }
189
214
  export function normalize(s) {
190
215
  return (s ?? "")
191
216
  .toLowerCase()
@@ -1,6 +1,11 @@
1
1
  import { CommandLineOptions } from "command-line-args";
2
2
  import { Reunion } from "../other_types/agenda.js";
3
- import { Candidate, MatchResult, MatchWeights } from "./types.js";
3
+ import { BestMatch, Candidate, MatchResult, MatchWeights } from "./types.js";
4
+ /**
5
+ * Sort comparator for ranked matches: higher score first; on (near-)equal scores,
6
+ * prefer the candidate temporally closest to the agenda start time.
7
+ */
8
+ export declare function compareRankedBest(a: BestMatch, b: BestMatch, agendaTs: number | null): number;
4
9
  export declare function matchOneReunion(args: {
5
10
  agenda: Reunion;
6
11
  agendaTs: number | null;
@@ -1,8 +1,21 @@
1
1
  import { isAmbiguousTimeOriginal } from "../utils/date.js";
2
2
  import { buildSenatVodMasterM3u8FromNvs, getLevel1Chapters, parseDataNvs } from "../utils/nvs-parsing.js";
3
- import { dice, getOrgKey, normalize, scoreVideo } from "../utils/scoring.js";
3
+ import { scoreVideo, computeOrgMatch } from "../utils/scoring.js";
4
4
  import { SENAT_DATAS_ROOT, weights } from "./config.js";
5
5
  import { fetchBuffer } from "./search.js";
6
+ /**
7
+ * Sort comparator for ranked matches: higher score first; on (near-)equal scores,
8
+ * prefer the candidate temporally closest to the agenda start time.
9
+ */
10
+ export function compareRankedBest(a, b, agendaTs) {
11
+ const diff = b.score - a.score;
12
+ if (Math.abs(diff) > 1e-9)
13
+ return diff;
14
+ if (agendaTs != null && a.epoch != null && b.epoch != null) {
15
+ return Math.abs(a.epoch - agendaTs) - Math.abs(b.epoch - agendaTs);
16
+ }
17
+ return 0;
18
+ }
6
19
  export async function matchOneReunion(args) {
7
20
  const { agenda, agendaTs, timeAmbigious, candidates, weights, fetchDataNvs, options } = args;
8
21
  if (!options["silent"])
@@ -42,25 +55,12 @@ export async function matchOneReunion(args) {
42
55
  continue;
43
56
  const meta = parseDataNvs(dataStr);
44
57
  let sameOrg = false;
45
- // Organe gate (same key OR strong dice)
58
+ // Organe gate (same key, near-identical strings OR strong dice)
46
59
  if (agenda.organe && meta.organes?.length) {
47
- const agendaOrgNorm = normalize(agenda.organe);
48
- const agendaKey = getOrgKey(agendaOrgNorm);
49
- let bestDice = 0;
50
- let hasSameKey = false;
51
- for (const vo of meta.organes) {
52
- const videoOrgNorm = normalize(vo);
53
- const videoKey = getOrgKey(videoOrgNorm);
54
- const d = dice(agendaOrgNorm, videoOrgNorm);
55
- if (videoKey === agendaKey && videoKey !== "autre")
56
- hasSameKey = true;
57
- if (d > bestDice)
58
- bestDice = d;
59
- }
60
- if (hasSameKey) {
61
- sameOrg = true;
62
- }
63
- else if (bestDice < orgSkipDice) {
60
+ const orgMatch = computeOrgMatch(agenda.organe, meta.organes);
61
+ sameOrg = orgMatch.sameOrg;
62
+ const bestDice = orgMatch.bestDice;
63
+ if (!sameOrg && bestDice < orgSkipDice) {
64
64
  continue;
65
65
  }
66
66
  }
@@ -92,7 +92,7 @@ export async function matchOneReunion(args) {
92
92
  }
93
93
  if (!ranked.length)
94
94
  return null;
95
- ranked.sort((a, b) => b.score - a.score);
95
+ ranked.sort((a, b) => compareRankedBest(a, b, agendaTs));
96
96
  const best = ranked[0];
97
97
  const second = ranked[1] ?? null;
98
98
  // Accept threshold (best must pass)
@@ -85,6 +85,11 @@ export async function processOneReunionMatch(args) {
85
85
  return;
86
86
  }
87
87
  const next = { ...obj, urlVideo: master, startTime: agenda.startTime, urlPageVideo: best?.pageUrl };
88
+ // Only an accepted match proves a video exists: flip captationVideo
89
+ // (it defaults to false for anything that is not a séance publique).
90
+ if (best != null) {
91
+ next.captationVideo = true;
92
+ }
88
93
  if (timecodeDebutVideo != null) {
89
94
  next.timecodeDebutVideo = timecodeDebutVideo;
90
95
  if (timecodeFinVideo != null)
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,100 @@
1
+ // TDD: when a video match is accepted (best != null), processOneReunionMatch must
2
+ // flip captationVideo to true in the agenda JSON (currently it writes urlVideo
3
+ // but leaves captationVideo false, causing urlVideo + captationVideo=false inconsistency).
4
+ import { describe, it, expect, afterEach } from "vitest";
5
+ import fs from "fs-extra";
6
+ import fsp from "fs/promises";
7
+ import os from "os";
8
+ import path from "path";
9
+ import { processOneReunionMatch, writeIfChanged } from "../src/videos/pipeline.js";
10
+ import { buildSenatVodMasterM3u8FromNvs, getAgendaSegmentTimecodes } from "../src/utils/nvs-parsing.js";
11
+ import { AGENDA_FOLDER, DATA_TRANSFORMED_FOLDER } from "../src/server/loaders.js";
12
+ const DATA_NVS = `<data>
13
+ <metadata name="date" value="1789041600"/>
14
+ <metadata name="salle" value="Salle Médicis"/>
15
+ <chapters>
16
+ <chapter id="1" label="Table ronde d&#39;élus locaux"/>
17
+ <chapter id="2" label="Clôture"/>
18
+ </chapters>
19
+ <serverfiles>serverfiles://senat/2026/09/encoder6_20260909134255.mp4</serverfiles>
20
+ </data>`;
21
+ const FINALPLAYER_NVS = `<player>
22
+ <synchro id="1" timecode="120000"/>
23
+ <synchro id="2" timecode="3600000"/>
24
+ </player>`;
25
+ const BEST_MATCH = {
26
+ id: "5942500",
27
+ hash: "6aa13cf51c220",
28
+ pageUrl: "https://videos.senat.fr/video.5942500_6aa13cf51c220.html",
29
+ epoch: 1789041600,
30
+ vtitle: "Table ronde d'élus locaux",
31
+ score: 1.09,
32
+ m3u8: "https://vodsenat.akamaized.net/senat/2026/09/encoder6_20260909134255.smil/master.m3u8",
33
+ signals: { titleScore: 0.7, orgScore: 0.1, salleScore: 0, timeScore: 0.2, sameOrg: true, timeAmbigious: false },
34
+ };
35
+ function makeAgenda(captationVideo) {
36
+ return {
37
+ uid: "RUSN20260909IDCCOCU559129",
38
+ chambre: "SN",
39
+ date: "2026-09-09",
40
+ startTime: "14:00:00.000Z",
41
+ endTime: null,
42
+ captationVideo,
43
+ titre: "MI Violences périscolaires - Table ronde d'élus locaux",
44
+ type: "Commissions",
45
+ organe: "Comité de contrôle",
46
+ objet: "Table ronde d'élus locaux",
47
+ events: [],
48
+ };
49
+ }
50
+ async function setupDataDir(agenda) {
51
+ const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "senat-pipeline-"));
52
+ const agendaJsonPath = path.join(dataDir, AGENDA_FOLDER, DATA_TRANSFORMED_FOLDER, "2025", `${agenda.uid}.json`);
53
+ await fs.ensureDir(path.dirname(agendaJsonPath));
54
+ await fsp.writeFile(agendaJsonPath, JSON.stringify(agenda, null, 2));
55
+ const baseDir = path.join(dataDir, "videos", "2025", agenda.uid);
56
+ await fs.ensureDir(baseDir);
57
+ await fsp.writeFile(path.join(baseDir, "data.nvs"), DATA_NVS);
58
+ await fsp.writeFile(path.join(baseDir, "finalplayer.nvs"), FINALPLAYER_NVS);
59
+ return { dataDir, agendaJsonPath, baseDir };
60
+ }
61
+ async function runPipeline(args) {
62
+ const lastByVideo = new Map();
63
+ await processOneReunionMatch({
64
+ agenda: args.agenda,
65
+ best: args.best,
66
+ baseDir: args.baseDir,
67
+ dataDir: args.dataDir,
68
+ session: 2025,
69
+ options: { silent: true },
70
+ writeIfChanged,
71
+ lastByVideo,
72
+ getAgendaSegmentTimecodes,
73
+ buildSenatVodMasterM3u8FromNvs,
74
+ });
75
+ return JSON.parse(await fsp.readFile(args.agendaJsonPath, "utf-8"));
76
+ }
77
+ describe("processOneReunionMatch — captationVideo", () => {
78
+ let dirs;
79
+ afterEach(async () => {
80
+ if (dirs?.dataDir)
81
+ await fs.remove(dirs.dataDir);
82
+ });
83
+ it("flips captationVideo to true when a match is accepted", async () => {
84
+ dirs = await setupDataDir(makeAgenda(false));
85
+ const result = await runPipeline({ ...dirs, agenda: makeAgenda(false), best: BEST_MATCH });
86
+ expect(result.captationVideo).toBe(true);
87
+ expect(result.urlVideo).toBe(BEST_MATCH.m3u8);
88
+ expect(result.urlPageVideo).toBe(BEST_MATCH.pageUrl);
89
+ });
90
+ it("does not flip captationVideo when there is no accepted match (best=null)", async () => {
91
+ dirs = await setupDataDir(makeAgenda(false));
92
+ const result = await runPipeline({ ...dirs, agenda: makeAgenda(false), best: null });
93
+ expect(result.captationVideo).toBe(false);
94
+ });
95
+ it("keeps captationVideo true when already true and no accepted match", async () => {
96
+ dirs = await setupDataDir(makeAgenda(true));
97
+ const result = await runPipeline({ ...dirs, agenda: makeAgenda(true), best: null });
98
+ expect(result.captationVideo).toBe(true);
99
+ });
100
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,68 @@
1
+ // TDD: deux artefacts de scoring font rejeter de vrais matchs malgré minAccept=0.9
2
+ // 1) couverture asymétrique: titre vidéo court entièrement contenu dans le titre agenda long
3
+ // 2) organes strictement identiques mais non mappés par getOrgKey ("autre") => sameOrg=false à tort
4
+ import { describe, it, expect } from "vitest";
5
+ import { similarityScore, computeOrgMatch } from "../src/utils/scoring.js";
6
+ import { compareRankedBest } from "../src/videos/match.js";
7
+ function makeBest(overrides) {
8
+ return {
9
+ hash: "h",
10
+ pageUrl: "https://videos.senat.fr/x.html",
11
+ score: 1,
12
+ m3u8: "m",
13
+ signals: { titleScore: 0, orgScore: 0, salleScore: 0, timeScore: 0, sameOrg: true, timeAmbigious: false },
14
+ ...overrides,
15
+ };
16
+ }
17
+ describe("compareRankedBest — tie-break par proximité temporelle", () => {
18
+ const agendaTs = 1000;
19
+ it("scores égaux: le candidat le plus proche de l'horaire agenda passe en premier", () => {
20
+ const apresMidi = makeBest({ id: "apres-midi", score: 0.644, epoch: 910 });
21
+ const soir = makeBest({ id: "soir", score: 0.644, epoch: 600 });
22
+ expect(compareRankedBest(soir, apresMidi, agendaTs)).toBeGreaterThan(0);
23
+ expect(compareRankedBest(apresMidi, soir, agendaTs)).toBeLessThan(0);
24
+ });
25
+ it("score différent: le meilleur score passe en premier (comportement inchangé)", () => {
26
+ const haut = makeBest({ id: "haut", score: 0.8, epoch: 600 });
27
+ const bas = makeBest({ id: "bas", score: 0.6, epoch: 999 });
28
+ expect(compareRankedBest(haut, bas, agendaTs)).toBeLessThan(0);
29
+ expect(compareRankedBest(bas, haut, agendaTs)).toBeGreaterThan(0);
30
+ });
31
+ it("scores égaux sans epoch: ordre stable", () => {
32
+ const a = makeBest({ id: "a", score: 0.6 });
33
+ const b = makeBest({ id: "b", score: 0.6 });
34
+ expect(compareRankedBest(a, b, null)).toBe(0);
35
+ });
36
+ });
37
+ describe("similarityScore — couverture symétrique", () => {
38
+ it("score haut quand le titre vidéo est entièrement contenu dans le titre agenda (ordre des mots quelconque)", () => {
39
+ const s = similarityScore("Conférence de presse : Universités : conclusions de la commission d'enquête", "Universités : conférence de presse");
40
+ expect(s).toBeCloseTo(0.9, 5);
41
+ });
42
+ it("cas conférence de presse Prélèvements obligatoires >= 0.9", () => {
43
+ const s = similarityScore("Conférence de presse : Prélèvements obligatoires sur les entreprises", "Prélèvements obligatoires : conférence de presse");
44
+ expect(s).toBeGreaterThanOrEqual(0.9);
45
+ });
46
+ it("restes ~0 pour des textes sans rapport", () => {
47
+ const s = similarityScore("Examen du projet de rapport de la commission d'enquête", "Audition de la brigade de protection des mineurs de Paris");
48
+ expect(s).toBeLessThan(0.3);
49
+ });
50
+ });
51
+ describe("computeOrgMatch", () => {
52
+ it("organes identiques non mappés par getOrgKey => sameOrg=true", () => {
53
+ const r = computeOrgMatch("MI Prélèvements obligatoires", ["MI Prélèvements obligatoires"]);
54
+ expect(r.sameOrg).toBe(true);
55
+ expect(r.bestDice).toBe(1);
56
+ });
57
+ it("même clé d'organe => sameOrg=true", () => {
58
+ const r = computeOrgMatch("Commission des lois", ["Commission des lois constitutionnelles"]);
59
+ expect(r.sameOrg).toBe(true);
60
+ });
61
+ it("organes sans rapport => sameOrg=false", () => {
62
+ const r = computeOrgMatch("CE Universités", [
63
+ "Commission de la culture, de l'éducation, de la communication et du sport",
64
+ ]);
65
+ expect(r.sameOrg).toBe(false);
66
+ expect(r.bestDice).toBeLessThan(0.8);
67
+ });
68
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tricoteuses/senat",
3
- "version": "3.3.1",
3
+ "version": "3.3.2",
4
4
  "description": "Handle French Sénat's open data",
5
5
  "keywords": [
6
6
  "France",