1347 lines
44 KiB
Markdown
1347 lines
44 KiB
Markdown
# Meeting-End Auto-Import Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Automate `/meeting-end` to fetch Teams transcript + Copilot recap via MS Graph, merge with live notes, update meeting note and agenda.
|
|
|
|
**Architecture:** Stateless Node script `fetch-meeting-artifacts.js` queries Graph for OnlineMeeting → transcript (.vtt) → aiInsights (Copilot recap) → recording URL. Claude in `/meeting-end` consumes JSON output, performs dual-pass synthesis (recap+transcript), merges with live notes, and runs agenda feedback with confidence heuristic.
|
|
|
|
**Tech Stack:** Node.js (existing), `@azure/msal-node`, `@microsoft/microsoft-graph-client`, `node:test`. Reuses `scripts/lib/o365-calendar.js` auth pattern and `scripts/lib/person-matcher.js` matching pattern.
|
|
|
|
**Spec:** [[docs/superpowers/specs/2026-05-06-meeting-end-auto-import-design]]
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
**New files:**
|
|
- `scripts/lib/vtt-parser.js` — parse `.vtt` to plain text with speaker tags
|
|
- `scripts/lib/speaker-matcher.js` — match transcript speakers to person notes (wraps person-matcher)
|
|
- `scripts/lib/graph-meetings.js` — Graph helpers for OnlineMeeting / transcripts / aiInsights / recordings
|
|
- `scripts/fetch-meeting-artifacts.js` — CLI orchestrator
|
|
- `scripts/backfill-series-id.js` — one-shot CLI to set `o365_series_id` for given series
|
|
- `scripts/test/vtt-parser.test.js`
|
|
- `scripts/test/speaker-matcher.test.js`
|
|
- `scripts/test/graph-meetings.test.js`
|
|
- `scripts/test/fetch-meeting-artifacts.test.js`
|
|
|
|
**Modified files:**
|
|
- `.claude/commands/meeting-end.md` — extended workflow with auto-import
|
|
- `scripts/.env.example` — add note about new permissions
|
|
- `03 Bereiche/Jour Fixe/IT Team/Agenda IT Team.md` — add `o365_series_id`
|
|
- `03 Bereiche/Jour Fixe/LANdata/Agenda LANdata.md` — add `o365_series_id`
|
|
|
|
---
|
|
|
|
## Task 1: VTT Parser
|
|
|
|
**Files:**
|
|
- Create: `scripts/lib/vtt-parser.js`
|
|
- Test: `scripts/test/vtt-parser.test.js`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
```javascript
|
|
// scripts/test/vtt-parser.test.js
|
|
const { describe, it } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { parseVtt } = require('../lib/vtt-parser.js');
|
|
|
|
describe('parseVtt', () => {
|
|
it('parses single cue with speaker tag', () => {
|
|
const vtt = `WEBVTT
|
|
|
|
00:00:01.000 --> 00:00:04.000
|
|
<v Christian Kauer>Hallo zusammen.</v>
|
|
`;
|
|
const result = parseVtt(vtt);
|
|
assert.deepEqual(result, [
|
|
{ speaker: 'Christian Kauer', text: 'Hallo zusammen.', start: '00:00:01.000', end: '00:00:04.000' }
|
|
]);
|
|
});
|
|
|
|
it('merges consecutive cues from same speaker', () => {
|
|
const vtt = `WEBVTT
|
|
|
|
00:00:01.000 --> 00:00:04.000
|
|
<v Frank Herberg>Wir können das prüfen.</v>
|
|
|
|
00:00:04.500 --> 00:00:07.000
|
|
<v Frank Herberg>Lass uns Borgstedt fragen.</v>
|
|
`;
|
|
const result = parseVtt(vtt, { mergeConsecutive: true });
|
|
assert.equal(result.length, 1);
|
|
assert.equal(result[0].speaker, 'Frank Herberg');
|
|
assert.equal(result[0].text, 'Wir können das prüfen. Lass uns Borgstedt fragen.');
|
|
});
|
|
|
|
it('handles cues without speaker tag', () => {
|
|
const vtt = `WEBVTT
|
|
|
|
00:00:01.000 --> 00:00:02.000
|
|
[Hintergrundgeräusch]
|
|
`;
|
|
const result = parseVtt(vtt);
|
|
assert.equal(result[0].speaker, null);
|
|
assert.equal(result[0].text, '[Hintergrundgeräusch]');
|
|
});
|
|
|
|
it('returns empty array for empty vtt', () => {
|
|
assert.deepEqual(parseVtt('WEBVTT\n\n'), []);
|
|
});
|
|
});
|
|
|
|
describe('formatTranscript', () => {
|
|
const { formatTranscript } = require('../lib/vtt-parser.js');
|
|
|
|
it('formats cues as speaker: text lines', () => {
|
|
const cues = [
|
|
{ speaker: 'Christian Kauer', text: 'Hallo.' },
|
|
{ speaker: 'Frank Herberg', text: 'Hi.' }
|
|
];
|
|
assert.equal(formatTranscript(cues), 'Christian Kauer: Hallo.\nFrank Herberg: Hi.');
|
|
});
|
|
|
|
it('uses [unknown] for null speaker', () => {
|
|
const cues = [{ speaker: null, text: '[Lärm]' }];
|
|
assert.equal(formatTranscript(cues), '[unknown]: [Lärm]');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```
|
|
cd scripts && node --test test/vtt-parser.test.js
|
|
```
|
|
Expected: FAIL — `Cannot find module '../lib/vtt-parser.js'`
|
|
|
|
- [ ] **Step 3: Implement vtt-parser**
|
|
|
|
```javascript
|
|
// scripts/lib/vtt-parser.js
|
|
function parseVtt(vtt, options = {}) {
|
|
const { mergeConsecutive = false } = options;
|
|
const lines = vtt.split(/\r?\n/);
|
|
const cues = [];
|
|
let i = 0;
|
|
|
|
// skip header
|
|
while (i < lines.length && !/-->/.test(lines[i])) i++;
|
|
|
|
while (i < lines.length) {
|
|
const timeLine = lines[i];
|
|
const timeMatch = timeLine.match(/(\d\d:\d\d:\d\d\.\d{3})\s+-->\s+(\d\d:\d\d:\d\d\.\d{3})/);
|
|
if (!timeMatch) { i++; continue; }
|
|
const start = timeMatch[1];
|
|
const end = timeMatch[2];
|
|
i++;
|
|
|
|
const textLines = [];
|
|
while (i < lines.length && lines[i].trim() !== '' && !/-->/.test(lines[i])) {
|
|
textLines.push(lines[i]);
|
|
i++;
|
|
}
|
|
const raw = textLines.join(' ').trim();
|
|
if (!raw) continue;
|
|
|
|
const speakerMatch = raw.match(/^<v\s+([^>]+)>([\s\S]*?)<\/v>$/);
|
|
if (speakerMatch) {
|
|
cues.push({ speaker: speakerMatch[1].trim(), text: speakerMatch[2].trim(), start, end });
|
|
} else {
|
|
cues.push({ speaker: null, text: raw, start, end });
|
|
}
|
|
}
|
|
|
|
if (!mergeConsecutive) return cues;
|
|
|
|
const merged = [];
|
|
for (const cue of cues) {
|
|
const last = merged[merged.length - 1];
|
|
if (last && last.speaker === cue.speaker) {
|
|
last.text = `${last.text} ${cue.text}`;
|
|
last.end = cue.end;
|
|
} else {
|
|
merged.push({ ...cue });
|
|
}
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
function formatTranscript(cues) {
|
|
return cues.map(c => `${c.speaker || '[unknown]'}: ${c.text}`).join('\n');
|
|
}
|
|
|
|
module.exports = { parseVtt, formatTranscript };
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests, verify pass**
|
|
|
|
```
|
|
cd scripts && node --test test/vtt-parser.test.js
|
|
```
|
|
Expected: all pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add scripts/lib/vtt-parser.js scripts/test/vtt-parser.test.js
|
|
git commit -m "feat(scripts): add vtt parser with speaker tags"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Speaker Matcher
|
|
|
|
**Files:**
|
|
- Create: `scripts/lib/speaker-matcher.js`
|
|
- Test: `scripts/test/speaker-matcher.test.js`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
```javascript
|
|
// scripts/test/speaker-matcher.test.js
|
|
const { describe, it } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { matchSpeakers, replaceSpeakerNames } = require('../lib/speaker-matcher.js');
|
|
|
|
describe('matchSpeakers', () => {
|
|
const persons = [
|
|
{ file: 'Christian Kauer (KRAH).md', fm: { vorname: 'Christian', nachname: 'Kauer', email: 'c.kauer@krah-gruppe.de' } },
|
|
{ file: 'Frank Herberg.md', fm: { vorname: 'Frank', nachname: 'Herberg', email: 'f.herberg@krah-gruppe.de' } },
|
|
{ file: 'Stefan Theile-Ochel.md', fm: { vorname: 'Stefan', nachname: 'Theile-Ochel', email: 's.theile-ochel@krah-gruppe.de' } }
|
|
];
|
|
|
|
it('matches by email when available', () => {
|
|
const speakers = [{ name: 'Christian Kauer', email: 'c.kauer@krah-gruppe.de' }];
|
|
const map = matchSpeakers(speakers, persons);
|
|
assert.equal(map.get('Christian Kauer'), '[[00 Kontext/Personen/Christian Kauer (KRAH)]]');
|
|
});
|
|
|
|
it('matches by full name when email missing', () => {
|
|
const speakers = [{ name: 'Frank Herberg' }];
|
|
const map = matchSpeakers(speakers, persons);
|
|
assert.equal(map.get('Frank Herberg'), '[[00 Kontext/Personen/Frank Herberg]]');
|
|
});
|
|
|
|
it('returns null wikilink for unmatched speaker', () => {
|
|
const speakers = [{ name: 'Unknown Person' }];
|
|
const map = matchSpeakers(speakers, persons);
|
|
assert.equal(map.get('Unknown Person'), null);
|
|
});
|
|
|
|
it('handles multiple speakers in one call', () => {
|
|
const speakers = [
|
|
{ name: 'Christian Kauer' },
|
|
{ name: 'Frank Herberg' },
|
|
{ name: 'Foo Bar' }
|
|
];
|
|
const map = matchSpeakers(speakers, persons);
|
|
assert.equal(map.size, 3);
|
|
assert.ok(map.get('Christian Kauer'));
|
|
assert.equal(map.get('Foo Bar'), null);
|
|
});
|
|
});
|
|
|
|
describe('replaceSpeakerNames', () => {
|
|
it('replaces speaker names with wikilinks in text', () => {
|
|
const map = new Map([
|
|
['Christian Kauer', '[[00 Kontext/Personen/Christian Kauer (KRAH)]]'],
|
|
['Frank Herberg', '[[00 Kontext/Personen/Frank Herberg]]']
|
|
]);
|
|
const text = 'Christian Kauer: Hallo. Frank Herberg: Hi Christian Kauer.';
|
|
const out = replaceSpeakerNames(text, map);
|
|
assert.ok(out.includes('[[00 Kontext/Personen/Christian Kauer (KRAH)]]'));
|
|
assert.ok(out.includes('[[00 Kontext/Personen/Frank Herberg]]'));
|
|
assert.ok(!out.includes('Christian Kauer:'));
|
|
});
|
|
|
|
it('skips unmatched speakers (null wikilink)', () => {
|
|
const map = new Map([['Unknown', null]]);
|
|
const out = replaceSpeakerNames('Unknown: Hello.', map);
|
|
assert.equal(out, 'Unknown: Hello.');
|
|
});
|
|
|
|
it('does longest-name first to avoid partial overlaps', () => {
|
|
const map = new Map([
|
|
['Christian Kauer', '[[Christian Kauer]]'],
|
|
['Christian', '[[Christian Hermann]]']
|
|
]);
|
|
const out = replaceSpeakerNames('Christian Kauer hat gesprochen.', map);
|
|
assert.ok(out.includes('[[Christian Kauer]] hat gesprochen.'));
|
|
assert.ok(!out.includes('[[Christian Hermann]]'));
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```
|
|
cd scripts && node --test test/speaker-matcher.test.js
|
|
```
|
|
Expected: FAIL — module not found.
|
|
|
|
- [ ] **Step 3: Implement speaker-matcher**
|
|
|
|
```javascript
|
|
// scripts/lib/speaker-matcher.js
|
|
const { matchAttendeeToPersons, loadPersons } = require('./person-matcher.js');
|
|
|
|
function matchSpeakers(speakers, personsList) {
|
|
const persons = personsList || loadPersons();
|
|
const map = new Map();
|
|
for (const speaker of speakers) {
|
|
const attendee = { name: speaker.name, email: speaker.email || '' };
|
|
const match = matchAttendeeToPersons(attendee, persons);
|
|
if (match.matched) {
|
|
const personName = match.file.replace('.md', '');
|
|
map.set(speaker.name, `[[00 Kontext/Personen/${personName}]]`);
|
|
} else {
|
|
map.set(speaker.name, null);
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function escapeRegex(s) {
|
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
function replaceSpeakerNames(text, map) {
|
|
// sort by name length DESC so "Christian Kauer" replaces before "Christian"
|
|
const entries = [...map.entries()]
|
|
.filter(([_, link]) => link !== null)
|
|
.sort((a, b) => b[0].length - a[0].length);
|
|
|
|
let out = text;
|
|
for (const [name, link] of entries) {
|
|
const re = new RegExp(escapeRegex(name), 'g');
|
|
out = out.replace(re, link);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
module.exports = { matchSpeakers, replaceSpeakerNames };
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests, verify pass**
|
|
|
|
```
|
|
cd scripts && node --test test/speaker-matcher.test.js
|
|
```
|
|
Expected: all pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add scripts/lib/speaker-matcher.js scripts/test/speaker-matcher.test.js
|
|
git commit -m "feat(scripts): add speaker matcher with wikilink replacement"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Graph Meetings Helper — Auth + Resolve OnlineMeeting
|
|
|
|
**Files:**
|
|
- Create: `scripts/lib/graph-meetings.js`
|
|
- Test: `scripts/test/graph-meetings.test.js`
|
|
|
|
The Graph client is injectable (so tests can mock it). The auth helper is reused from `o365-calendar.js` pattern.
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
```javascript
|
|
// scripts/test/graph-meetings.test.js
|
|
const { describe, it } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const {
|
|
resolveOnlineMeeting,
|
|
fetchTranscriptVtt,
|
|
fetchAiInsights,
|
|
fetchRecordingUrl
|
|
} = require('../lib/graph-meetings.js');
|
|
|
|
function fakeClient(routes) {
|
|
return {
|
|
api(path) {
|
|
const handler = routes[path];
|
|
return {
|
|
query: () => this,
|
|
select: () => this,
|
|
get: async () => {
|
|
if (!handler) throw Object.assign(new Error('Not found'), { statusCode: 404 });
|
|
if (typeof handler === 'function') return handler();
|
|
return handler;
|
|
},
|
|
getStream: async () => handler?.stream
|
|
};
|
|
}
|
|
};
|
|
}
|
|
|
|
describe('resolveOnlineMeeting', () => {
|
|
it('resolves meeting by joinWebUrl', async () => {
|
|
const client = fakeClient({
|
|
"/users/u/onlineMeetings": { value: [{ id: 'M1', joinWebUrl: 'https://teams/j' }] }
|
|
});
|
|
const m = await resolveOnlineMeeting(client, 'u', 'https://teams/j');
|
|
assert.equal(m.id, 'M1');
|
|
});
|
|
|
|
it('returns null when no meeting matches', async () => {
|
|
const client = fakeClient({
|
|
"/users/u/onlineMeetings": { value: [] }
|
|
});
|
|
const m = await resolveOnlineMeeting(client, 'u', 'https://teams/j');
|
|
assert.equal(m, null);
|
|
});
|
|
});
|
|
|
|
describe('fetchTranscriptVtt', () => {
|
|
it('returns latest transcript content as string', async () => {
|
|
const client = fakeClient({
|
|
"/users/u/onlineMeetings/M1/transcripts": {
|
|
value: [
|
|
{ id: 'T1', createdDateTime: '2026-05-06T09:00:00Z' },
|
|
{ id: 'T2', createdDateTime: '2026-05-06T10:00:00Z' }
|
|
]
|
|
},
|
|
"/users/u/onlineMeetings/M1/transcripts/T2/content": 'WEBVTT\n\n00:00:01.000 --> 00:00:02.000\n<v X>Y</v>\n'
|
|
});
|
|
const vtt = await fetchTranscriptVtt(client, 'u', 'M1');
|
|
assert.ok(vtt.startsWith('WEBVTT'));
|
|
});
|
|
|
|
it('returns null when no transcripts exist', async () => {
|
|
const client = fakeClient({
|
|
"/users/u/onlineMeetings/M1/transcripts": { value: [] }
|
|
});
|
|
const vtt = await fetchTranscriptVtt(client, 'u', 'M1');
|
|
assert.equal(vtt, null);
|
|
});
|
|
});
|
|
|
|
describe('fetchAiInsights', () => {
|
|
it('returns insights when available', async () => {
|
|
const client = fakeClient({
|
|
"/users/u/onlineMeetings/M1/aiInsights": {
|
|
value: [{ id: 'I1', actionItems: [], meetingNotes: [], mentions: [] }]
|
|
}
|
|
});
|
|
const out = await fetchAiInsights(client, 'u', 'M1');
|
|
assert.ok(out);
|
|
assert.equal(out.id, 'I1');
|
|
});
|
|
|
|
it('returns null on 404', async () => {
|
|
const client = fakeClient({});
|
|
const out = await fetchAiInsights(client, 'u', 'M1');
|
|
assert.equal(out, null);
|
|
});
|
|
});
|
|
|
|
describe('fetchRecordingUrl', () => {
|
|
it('returns recordings url metadata when available', async () => {
|
|
const client = fakeClient({
|
|
"/users/u/onlineMeetings/M1/recordings": {
|
|
value: [{ id: 'R1', recordingContentUrl: 'https://teams/play/R1', createdDateTime: '2026-05-06T11:00:00Z' }]
|
|
}
|
|
});
|
|
const out = await fetchRecordingUrl(client, 'u', 'M1');
|
|
assert.equal(out, 'https://teams/play/R1');
|
|
});
|
|
|
|
it('returns null when no recording', async () => {
|
|
const client = fakeClient({
|
|
"/users/u/onlineMeetings/M1/recordings": { value: [] }
|
|
});
|
|
const out = await fetchRecordingUrl(client, 'u', 'M1');
|
|
assert.equal(out, null);
|
|
});
|
|
|
|
it('returns null on permission denied', async () => {
|
|
const client = fakeClient({});
|
|
const out = await fetchRecordingUrl(client, 'u', 'M1');
|
|
assert.equal(out, null);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```
|
|
cd scripts && node --test test/graph-meetings.test.js
|
|
```
|
|
Expected: FAIL — module not found.
|
|
|
|
- [ ] **Step 3: Implement graph-meetings**
|
|
|
|
```javascript
|
|
// scripts/lib/graph-meetings.js
|
|
const { ConfidentialClientApplication } = require('@azure/msal-node');
|
|
const { Client } = require('@microsoft/microsoft-graph-client');
|
|
const { readFileSync } = require('node:fs');
|
|
const { resolve } = require('node:path');
|
|
|
|
function loadEnv() {
|
|
const envPath = resolve(__dirname, '..', '.env');
|
|
const content = readFileSync(envPath, 'utf-8');
|
|
const vars = {};
|
|
for (const line of content.split('\n')) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
const [key, ...rest] = trimmed.split('=');
|
|
vars[key.trim()] = rest.join('=').trim();
|
|
}
|
|
return vars;
|
|
}
|
|
|
|
async function buildGraphClient(env = loadEnv()) {
|
|
const cca = new ConfidentialClientApplication({
|
|
auth: {
|
|
clientId: env.AZURE_CLIENT_ID,
|
|
clientSecret: env.AZURE_CLIENT_SECRET,
|
|
authority: `https://login.microsoftonline.com/${env.AZURE_TENANT_ID}`
|
|
}
|
|
});
|
|
const tokenResponse = await cca.acquireTokenByClientCredential({
|
|
scopes: ['https://graph.microsoft.com/.default']
|
|
});
|
|
return Client.init({
|
|
authProvider: (done) => done(null, tokenResponse.accessToken),
|
|
defaultVersion: 'v1.0'
|
|
});
|
|
}
|
|
|
|
async function safeGet(client, path, options = {}) {
|
|
try {
|
|
let req = client.api(path);
|
|
if (options.version) req = req.version(options.version);
|
|
if (options.query) req = req.query(options.query);
|
|
return await req.get();
|
|
} catch (err) {
|
|
if (err.statusCode === 404 || err.statusCode === 403) return null;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function resolveOnlineMeeting(client, userId, joinWebUrl) {
|
|
const path = `/users/${userId}/onlineMeetings`;
|
|
let req = client.api(path);
|
|
if (typeof req.query === 'function') {
|
|
req = req.query({ $filter: `JoinWebUrl eq '${joinWebUrl}'` });
|
|
}
|
|
const resp = await req.get();
|
|
const list = resp?.value || [];
|
|
return list[0] || null;
|
|
}
|
|
|
|
async function fetchTranscriptVtt(client, userId, meetingId) {
|
|
const list = await safeGet(client, `/users/${userId}/onlineMeetings/${meetingId}/transcripts`);
|
|
const items = list?.value || [];
|
|
if (items.length === 0) return null;
|
|
const latest = [...items].sort((a, b) =>
|
|
new Date(b.createdDateTime) - new Date(a.createdDateTime)
|
|
)[0];
|
|
const contentPath = `/users/${userId}/onlineMeetings/${meetingId}/transcripts/${latest.id}/content`;
|
|
return await safeGet(client, contentPath);
|
|
}
|
|
|
|
async function fetchAiInsights(client, userId, meetingId) {
|
|
// beta endpoint
|
|
const path = `/users/${userId}/onlineMeetings/${meetingId}/aiInsights`;
|
|
const resp = await safeGet(client, path, { version: 'beta' });
|
|
const items = resp?.value || [];
|
|
return items[0] || null;
|
|
}
|
|
|
|
async function fetchRecordingUrl(client, userId, meetingId) {
|
|
const list = await safeGet(client, `/users/${userId}/onlineMeetings/${meetingId}/recordings`);
|
|
const items = list?.value || [];
|
|
if (items.length === 0) return null;
|
|
const latest = [...items].sort((a, b) =>
|
|
new Date(b.createdDateTime) - new Date(a.createdDateTime)
|
|
)[0];
|
|
return latest.recordingContentUrl || latest.contentCorrelationId || null;
|
|
}
|
|
|
|
module.exports = {
|
|
loadEnv,
|
|
buildGraphClient,
|
|
resolveOnlineMeeting,
|
|
fetchTranscriptVtt,
|
|
fetchAiInsights,
|
|
fetchRecordingUrl
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests, verify pass**
|
|
|
|
```
|
|
cd scripts && node --test test/graph-meetings.test.js
|
|
```
|
|
Expected: all pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add scripts/lib/graph-meetings.js scripts/test/graph-meetings.test.js
|
|
git commit -m "feat(scripts): add graph meetings helper with safe fallbacks"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Calendar Event Fetcher — get event by id
|
|
|
|
**Files:**
|
|
- Modify: `scripts/lib/o365-calendar.js`
|
|
- Test: `scripts/test/o365-calendar.test.js`
|
|
|
|
Add ability to fetch a single event by its `o365_id` (we need its `joinWebUrl`).
|
|
|
|
- [ ] **Step 1: Write failing test**
|
|
|
|
Append to `scripts/test/o365-calendar.test.js`:
|
|
|
|
```javascript
|
|
const { extractJoinUrlFromBody } = require('../lib/o365-calendar.js');
|
|
|
|
describe('extractJoinUrlFromBody', () => {
|
|
it('extracts teams meeting join URL from body html', () => {
|
|
const html = '<a href="https://teams.microsoft.com/l/meetup-join/19%3aabc/0?context=foo">Join</a>';
|
|
assert.equal(
|
|
extractJoinUrlFromBody(html),
|
|
'https://teams.microsoft.com/l/meetup-join/19%3aabc/0?context=foo'
|
|
);
|
|
});
|
|
|
|
it('returns null when no teams url present', () => {
|
|
assert.equal(extractJoinUrlFromBody('<p>nothing</p>'), null);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test, verify fail**
|
|
|
|
```
|
|
cd scripts && node --test test/o365-calendar.test.js
|
|
```
|
|
Expected: FAIL — `extractJoinUrlFromBody is not a function`.
|
|
|
|
- [ ] **Step 3: Implement helper**
|
|
|
|
Add to `scripts/lib/o365-calendar.js` (before `module.exports`):
|
|
|
|
```javascript
|
|
function extractJoinUrlFromBody(body) {
|
|
if (!body) return null;
|
|
const m = body.match(/https:\/\/teams\.microsoft\.com\/l\/meetup-join\/[^\s"'<>]+/);
|
|
return m ? m[0] : null;
|
|
}
|
|
|
|
async function getEventById(eventId) {
|
|
const env = loadEnv();
|
|
const cca = new ConfidentialClientApplication({
|
|
auth: {
|
|
clientId: env.AZURE_CLIENT_ID,
|
|
clientSecret: env.AZURE_CLIENT_SECRET,
|
|
authority: `https://login.microsoftonline.com/${env.AZURE_TENANT_ID}`
|
|
}
|
|
});
|
|
const tokenResponse = await cca.acquireTokenByClientCredential({
|
|
scopes: ['https://graph.microsoft.com/.default']
|
|
});
|
|
const client = Client.init({
|
|
authProvider: (done) => done(null, tokenResponse.accessToken)
|
|
});
|
|
|
|
const event = await client
|
|
.api(`/users/${env.AZURE_USER_EMAIL}/events/${eventId}`)
|
|
.select('id,subject,start,end,body,attendees,seriesMasterId,onlineMeeting')
|
|
.get();
|
|
return event;
|
|
}
|
|
```
|
|
|
|
Update `module.exports`:
|
|
|
|
```javascript
|
|
module.exports = {
|
|
isRecurring, parseEventToMeeting, formatEventChoice,
|
|
getCalendarEvents, getEventById, extractJoinUrlFromBody
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests, verify pass**
|
|
|
|
```
|
|
cd scripts && node --test test/o365-calendar.test.js
|
|
```
|
|
Expected: all pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add scripts/lib/o365-calendar.js scripts/test/o365-calendar.test.js
|
|
git commit -m "feat(scripts): add getEventById and join-url extractor"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: CLI Orchestrator `fetch-meeting-artifacts.js`
|
|
|
|
**Files:**
|
|
- Create: `scripts/fetch-meeting-artifacts.js`
|
|
- Test: `scripts/test/fetch-meeting-artifacts.test.js`
|
|
|
|
The CLI:
|
|
- Accepts `--o365-id <id>` (preferred) or `--join-url <url>`
|
|
- Calls `getEventById` → extracts joinUrl → `resolveOnlineMeeting` → `fetchTranscriptVtt` + `fetchAiInsights` + `fetchRecordingUrl`
|
|
- Output JSON to stdout
|
|
- `--out <file>` to write file instead
|
|
|
|
Logic is split into a pure `runFetch(client, userId, opts)` for unit tests, plus a thin CLI wrapper.
|
|
|
|
- [ ] **Step 1: Write failing test**
|
|
|
|
```javascript
|
|
// scripts/test/fetch-meeting-artifacts.test.js
|
|
const { describe, it } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { runFetch } = require('../fetch-meeting-artifacts.js');
|
|
|
|
function makeClient(map) {
|
|
return {
|
|
api(path) {
|
|
const h = map[path];
|
|
return {
|
|
query: () => this,
|
|
select: () => this,
|
|
version: () => this,
|
|
get: async () => {
|
|
if (h === undefined) throw Object.assign(new Error('nf'), { statusCode: 404 });
|
|
return typeof h === 'function' ? h() : h;
|
|
}
|
|
};
|
|
}
|
|
};
|
|
}
|
|
|
|
describe('runFetch', () => {
|
|
it('returns full artifact bundle for happy path', async () => {
|
|
const userId = 'u@krah.de';
|
|
const client = makeClient({
|
|
[`/users/${userId}/events/E1`]: {
|
|
id: 'E1', subject: 'Jour Fixe IT Team', seriesMasterId: 'S1',
|
|
start: { dateTime: '2026-05-06T09:00:00.0000000', timeZone: 'UTC' },
|
|
end: { dateTime: '2026-05-06T10:00:00.0000000', timeZone: 'UTC' },
|
|
attendees: [{ emailAddress: { name: 'Christian Kauer', address: 'c.kauer@krah-gruppe.de' } }],
|
|
body: { contentType: 'html', content: '<a href="https://teams.microsoft.com/l/meetup-join/19%3aXYZ/0">Join</a>' }
|
|
},
|
|
[`/users/${userId}/onlineMeetings`]: { value: [{ id: 'M1', joinWebUrl: 'https://teams.microsoft.com/l/meetup-join/19%3aXYZ/0' }] },
|
|
[`/users/${userId}/onlineMeetings/M1/transcripts`]: { value: [{ id: 'T1', createdDateTime: '2026-05-06T10:01:00Z' }] },
|
|
[`/users/${userId}/onlineMeetings/M1/transcripts/T1/content`]: 'WEBVTT\n\n00:00:01.000 --> 00:00:02.000\n<v Christian Kauer>Hallo.</v>\n',
|
|
[`/users/${userId}/onlineMeetings/M1/aiInsights`]: { value: [{ id: 'I1', meetingNotes: [{ title: 'Topic', text: 'Notiz' }] }] },
|
|
[`/users/${userId}/onlineMeetings/M1/recordings`]: { value: [{ id: 'R1', recordingContentUrl: 'https://teams/play/R1', createdDateTime: '2026-05-06T10:30:00Z' }] }
|
|
});
|
|
|
|
const result = await runFetch(client, userId, { o365Id: 'E1' });
|
|
|
|
assert.equal(result.meeting.id, 'E1');
|
|
assert.equal(result.meeting.seriesMasterId, 'S1');
|
|
assert.equal(result.meeting.onlineMeetingId, 'M1');
|
|
assert.ok(result.transcript.includes('Christian Kauer: Hallo.'));
|
|
assert.equal(result.recap.id, 'I1');
|
|
assert.equal(result.recordingUrl, 'https://teams/play/R1');
|
|
});
|
|
|
|
it('handles missing transcript gracefully', async () => {
|
|
const userId = 'u@krah.de';
|
|
const client = makeClient({
|
|
[`/users/${userId}/events/E1`]: {
|
|
id: 'E1', subject: 'X', seriesMasterId: null,
|
|
start: { dateTime: '2026-05-06T09:00:00.0000000' },
|
|
end: { dateTime: '2026-05-06T10:00:00.0000000' },
|
|
attendees: [],
|
|
body: { contentType: 'html', content: '<a href="https://teams.microsoft.com/l/meetup-join/19%3aabc/0">J</a>' }
|
|
},
|
|
[`/users/${userId}/onlineMeetings`]: { value: [{ id: 'M1', joinWebUrl: 'https://teams.microsoft.com/l/meetup-join/19%3aabc/0' }] },
|
|
[`/users/${userId}/onlineMeetings/M1/transcripts`]: { value: [] },
|
|
[`/users/${userId}/onlineMeetings/M1/recordings`]: { value: [] }
|
|
});
|
|
const result = await runFetch(client, userId, { o365Id: 'E1' });
|
|
assert.equal(result.transcript, null);
|
|
assert.equal(result.recap, null);
|
|
assert.equal(result.recordingUrl, null);
|
|
});
|
|
|
|
it('returns error info when event has no teams join url', async () => {
|
|
const userId = 'u@krah.de';
|
|
const client = makeClient({
|
|
[`/users/${userId}/events/E1`]: {
|
|
id: 'E1', subject: 'X', seriesMasterId: null,
|
|
start: { dateTime: '2026-05-06T09:00:00.0000000' },
|
|
end: { dateTime: '2026-05-06T10:00:00.0000000' },
|
|
attendees: [],
|
|
body: { contentType: 'text', content: 'no join url here' }
|
|
}
|
|
});
|
|
const result = await runFetch(client, userId, { o365Id: 'E1' });
|
|
assert.equal(result.transcript, null);
|
|
assert.equal(result.recap, null);
|
|
assert.ok(result.warnings.some(w => /join url/i.test(w)));
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test, verify fail**
|
|
|
|
```
|
|
cd scripts && node --test test/fetch-meeting-artifacts.test.js
|
|
```
|
|
Expected: FAIL — module not found.
|
|
|
|
- [ ] **Step 3: Implement orchestrator**
|
|
|
|
```javascript
|
|
// scripts/fetch-meeting-artifacts.js
|
|
const { writeFileSync } = require('node:fs');
|
|
const {
|
|
loadEnv,
|
|
buildGraphClient,
|
|
resolveOnlineMeeting,
|
|
fetchTranscriptVtt,
|
|
fetchAiInsights,
|
|
fetchRecordingUrl
|
|
} = require('./lib/graph-meetings.js');
|
|
const { extractJoinUrlFromBody } = require('./lib/o365-calendar.js');
|
|
const { parseVtt, formatTranscript } = require('./lib/vtt-parser.js');
|
|
|
|
function parseArgs(argv) {
|
|
const out = {};
|
|
for (let i = 2; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a === '--o365-id') out.o365Id = argv[++i];
|
|
else if (a === '--join-url') out.joinUrl = argv[++i];
|
|
else if (a === '--out') out.out = argv[++i];
|
|
else if (a === '--user') out.user = argv[++i];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function runFetch(client, userId, opts) {
|
|
const warnings = [];
|
|
let event = null;
|
|
let joinUrl = opts.joinUrl || null;
|
|
let seriesMasterId = null;
|
|
|
|
if (opts.o365Id) {
|
|
event = await client.api(`/users/${userId}/events/${opts.o365Id}`)
|
|
.select('id,subject,start,end,body,attendees,seriesMasterId,onlineMeeting')
|
|
.get();
|
|
seriesMasterId = event.seriesMasterId || null;
|
|
if (!joinUrl) {
|
|
joinUrl = event.onlineMeeting?.joinUrl
|
|
|| extractJoinUrlFromBody(event.body?.content || '');
|
|
}
|
|
}
|
|
|
|
if (!joinUrl) {
|
|
warnings.push('no teams join url on event');
|
|
return {
|
|
meeting: event ? eventSummary(event) : null,
|
|
transcript: null, recap: null, recordingUrl: null, warnings
|
|
};
|
|
}
|
|
|
|
const om = await resolveOnlineMeeting(client, userId, joinUrl);
|
|
if (!om) {
|
|
warnings.push(`onlineMeeting not found for joinUrl=${joinUrl}`);
|
|
return {
|
|
meeting: event ? eventSummary(event) : null,
|
|
transcript: null, recap: null, recordingUrl: null, warnings
|
|
};
|
|
}
|
|
|
|
const [vtt, recap, recordingUrl] = await Promise.all([
|
|
fetchTranscriptVtt(client, userId, om.id).catch(e => { warnings.push(`transcript: ${e.message}`); return null; }),
|
|
fetchAiInsights(client, userId, om.id).catch(e => { warnings.push(`aiInsights: ${e.message}`); return null; }),
|
|
fetchRecordingUrl(client, userId, om.id).catch(e => { warnings.push(`recording: ${e.message}`); return null; })
|
|
]);
|
|
|
|
let transcript = null;
|
|
if (vtt) {
|
|
const cues = parseVtt(vtt, { mergeConsecutive: true });
|
|
transcript = formatTranscript(cues);
|
|
}
|
|
|
|
return {
|
|
meeting: event ? { ...eventSummary(event), onlineMeetingId: om.id, seriesMasterId } : { onlineMeetingId: om.id },
|
|
transcript,
|
|
recap,
|
|
recordingUrl,
|
|
warnings
|
|
};
|
|
}
|
|
|
|
function eventSummary(event) {
|
|
return {
|
|
id: event.id,
|
|
subject: event.subject,
|
|
start: event.start?.dateTime,
|
|
end: event.end?.dateTime,
|
|
seriesMasterId: event.seriesMasterId || null,
|
|
attendees: (event.attendees || []).map(a => ({
|
|
name: a.emailAddress?.name,
|
|
email: (a.emailAddress?.address || '').toLowerCase()
|
|
}))
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
const opts = parseArgs(process.argv);
|
|
if (!opts.o365Id && !opts.joinUrl) {
|
|
console.error('Usage: node fetch-meeting-artifacts.js --o365-id <id> [--out file.json]');
|
|
process.exit(2);
|
|
}
|
|
const env = loadEnv();
|
|
const userId = opts.user || env.AZURE_USER_EMAIL;
|
|
const client = await buildGraphClient(env);
|
|
const result = await runFetch(client, userId, opts);
|
|
const json = JSON.stringify(result, null, 2);
|
|
if (opts.out) writeFileSync(opts.out, json, 'utf-8');
|
|
else process.stdout.write(json + '\n');
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().catch(err => { console.error(err.stack || err.message); process.exit(1); });
|
|
}
|
|
|
|
module.exports = { runFetch, parseArgs };
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests, verify pass**
|
|
|
|
```
|
|
cd scripts && node --test test/fetch-meeting-artifacts.test.js
|
|
```
|
|
Expected: all pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add scripts/fetch-meeting-artifacts.js scripts/test/fetch-meeting-artifacts.test.js
|
|
git commit -m "feat(scripts): add fetch-meeting-artifacts orchestrator cli"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Backfill `o365_series_id` for IT Team and LANdata
|
|
|
|
**Files:**
|
|
- Create: `scripts/backfill-series-id.js`
|
|
- Test: `scripts/test/backfill-series-id.test.js`
|
|
- Modify: `03 Bereiche/Jour Fixe/IT Team/Agenda IT Team.md`
|
|
- Modify: `03 Bereiche/Jour Fixe/LANdata/Agenda LANdata.md`
|
|
|
|
The script reads a future occurrence of a recurring meeting in the calendar (matched by subject regex), extracts `seriesMasterId`, and writes it into the named Agenda file's frontmatter.
|
|
|
|
- [ ] **Step 1: Write failing test for frontmatter writer**
|
|
|
|
```javascript
|
|
// scripts/test/backfill-series-id.test.js
|
|
const { describe, it } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { upsertFrontmatterField } = require('../backfill-series-id.js');
|
|
|
|
describe('upsertFrontmatterField', () => {
|
|
it('inserts new field when missing', () => {
|
|
const md = `---\ntags: [jour-fixe]\nserie: IT Team\nrhythmus: wöchentlich\n---\n\n# Agenda\n`;
|
|
const out = upsertFrontmatterField(md, 'o365_series_id', 'AAMkSeries123');
|
|
assert.ok(out.includes('o365_series_id: AAMkSeries123'));
|
|
assert.ok(out.includes('serie: IT Team'));
|
|
assert.ok(out.endsWith('# Agenda\n'));
|
|
});
|
|
|
|
it('replaces existing field value', () => {
|
|
const md = `---\nserie: X\no365_series_id: OLD\n---\n\nbody\n`;
|
|
const out = upsertFrontmatterField(md, 'o365_series_id', 'NEW');
|
|
assert.ok(out.includes('o365_series_id: NEW'));
|
|
assert.ok(!out.includes('OLD'));
|
|
});
|
|
|
|
it('returns content unchanged when no frontmatter present', () => {
|
|
const md = `# No frontmatter\n`;
|
|
const out = upsertFrontmatterField(md, 'o365_series_id', 'X');
|
|
assert.equal(out, md);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test, verify fail**
|
|
|
|
```
|
|
cd scripts && node --test test/backfill-series-id.test.js
|
|
```
|
|
Expected: FAIL — module not found.
|
|
|
|
- [ ] **Step 3: Implement script**
|
|
|
|
```javascript
|
|
// scripts/backfill-series-id.js
|
|
const { readFileSync, writeFileSync } = require('node:fs');
|
|
const { resolve } = require('node:path');
|
|
const { getCalendarEvents } = require('./lib/o365-calendar.js');
|
|
|
|
function upsertFrontmatterField(md, key, value) {
|
|
const fmMatch = md.match(/^---\n([\s\S]*?)\n---/);
|
|
if (!fmMatch) return md;
|
|
const fm = fmMatch[1];
|
|
const lineRegex = new RegExp(`^${key}:.*$`, 'm');
|
|
let newFm;
|
|
if (lineRegex.test(fm)) {
|
|
newFm = fm.replace(lineRegex, `${key}: ${value}`);
|
|
} else {
|
|
newFm = `${fm}\n${key}: ${value}`;
|
|
}
|
|
return md.replace(/^---\n[\s\S]*?\n---/, `---\n${newFm}\n---`);
|
|
}
|
|
|
|
async function backfill(agendaPath, titleRegex, daysAhead = 30) {
|
|
const events = await getCalendarEvents(daysAhead);
|
|
const re = new RegExp(titleRegex, 'i');
|
|
const match = events.find(e => re.test(e.title) && e.isRecurring);
|
|
if (!match) {
|
|
throw new Error(`no recurring event matching /${titleRegex}/ in next ${daysAhead} days`);
|
|
}
|
|
// Need raw seriesMasterId — re-fetch single event by id (parseEventToMeeting drops it)
|
|
const { getEventById } = require('./lib/o365-calendar.js');
|
|
const raw = await getEventById(match.id);
|
|
const seriesId = raw.seriesMasterId;
|
|
if (!seriesId) {
|
|
throw new Error(`event ${match.id} has no seriesMasterId`);
|
|
}
|
|
const md = readFileSync(agendaPath, 'utf-8');
|
|
const updated = upsertFrontmatterField(md, 'o365_series_id', seriesId);
|
|
writeFileSync(agendaPath, updated, 'utf-8');
|
|
return seriesId;
|
|
}
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
if (args.length < 2) {
|
|
console.error('Usage: node backfill-series-id.js <agenda-path> <title-regex>');
|
|
process.exit(2);
|
|
}
|
|
const [agendaPath, titleRegex] = args;
|
|
const id = await backfill(resolve(agendaPath), titleRegex);
|
|
console.log(`set o365_series_id=${id} in ${agendaPath}`);
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().catch(e => { console.error(e.stack || e.message); process.exit(1); });
|
|
}
|
|
|
|
module.exports = { upsertFrontmatterField, backfill };
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests, verify pass**
|
|
|
|
```
|
|
cd scripts && node --test test/backfill-series-id.test.js
|
|
```
|
|
Expected: all pass.
|
|
|
|
- [ ] **Step 5: Run backfill against real Calendar (manual integration step)**
|
|
|
|
```bash
|
|
cd scripts
|
|
node backfill-series-id.js "../03 Bereiche/Jour Fixe/IT Team/Agenda IT Team.md" "Jour\\s*Fixe.*IT\\s*Team"
|
|
node backfill-series-id.js "../03 Bereiche/Jour Fixe/LANdata/Agenda LANdata.md" "Jour\\s*Fixe.*LANdata"
|
|
```
|
|
|
|
Expected: each prints `set o365_series_id=AAMk... in ...`. If a run fails (no future occurrence found), increase days-ahead by editing the script call to include a third arg, or fall back to lazy backfill on the next `/meeting`.
|
|
|
|
- [ ] **Step 6: Verify frontmatter changes**
|
|
|
|
```bash
|
|
git -C "D:/projects/chrka/brain" diff -- "03 Bereiche/Jour Fixe/IT Team/Agenda IT Team.md" "03 Bereiche/Jour Fixe/LANdata/Agenda LANdata.md"
|
|
```
|
|
Expected: each agenda gains `o365_series_id: AAMk...` line.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add scripts/backfill-series-id.js scripts/test/backfill-series-id.test.js \
|
|
"03 Bereiche/Jour Fixe/IT Team/Agenda IT Team.md" \
|
|
"03 Bereiche/Jour Fixe/LANdata/Agenda LANdata.md"
|
|
git commit -m "feat(jour-fixe): add backfill script and seed o365_series_id for IT Team/LANdata"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Lazy Backfill on `/meeting`
|
|
|
|
**Files:**
|
|
- Modify: `.claude/commands/meeting.md` (or `.claude/skills/jour-fixe.md` — wherever `/meeting` is defined)
|
|
|
|
For series other than IT Team/LANdata, on first `/meeting` we want Claude to write `o365_series_id` to the Agenda automatically when missing.
|
|
|
|
- [ ] **Step 1: Locate the meeting command**
|
|
|
|
```bash
|
|
ls -la "D:/projects/chrka/brain/.claude/commands/"
|
|
```
|
|
Expected: `meeting.md`, `meeting-end.md`, `meeting-manual.md` listed.
|
|
|
|
- [ ] **Step 2: Inspect current `/meeting` instructions**
|
|
|
|
Read `.claude/commands/meeting.md`. Identify the step where the agenda file is read.
|
|
|
|
- [ ] **Step 3: Add lazy-backfill instruction**
|
|
|
|
After the step that reads the agenda, insert an instruction (in German, matching the file's existing language) like:
|
|
|
|
```markdown
|
|
6a. **Falls die Agenda kein `o365_series_id` im Frontmatter hat** und der gewählte Termin eine Serie ist (`isRecurring: true` und `seriesMasterId` vorhanden):
|
|
- Trage `o365_series_id: <seriesMasterId>` ins Frontmatter der Agenda ein (zwischen `serie:` und `rhythmus:`)
|
|
- Bestätige im Bericht: "o365_series_id für Serie [Name] eingetragen."
|
|
```
|
|
|
|
(Adapt heading number to the actual flow.)
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add ".claude/commands/meeting.md"
|
|
git commit -m "feat(jour-fixe): lazy-backfill o365_series_id on /meeting"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Rewrite `/meeting-end` Command
|
|
|
|
**Files:**
|
|
- Modify: `.claude/commands/meeting-end.md`
|
|
|
|
Replace existing content with the auto-import flow.
|
|
|
|
- [ ] **Step 1: Replace command file**
|
|
|
|
Write the following exactly to `.claude/commands/meeting-end.md`:
|
|
|
|
```markdown
|
|
Schließe ein Jour-Fixe-Meeting ab, ziehe automatisch Recap+Transkript aus Teams und führe die Ergebnisse zurück.
|
|
|
|
## Schritt 1 — Meeting-Notiz identifizieren
|
|
|
|
- Wenn `current_note` eine Datei unter `03 Bereiche/Meetings/` ist → diese verwenden.
|
|
- Sonst: Serie und Datum aus `$ARGUMENTS` ermitteln. Pfad: `03 Bereiche/Meetings/YYYY-MM-DD <Titel>.md`.
|
|
- Lies das Frontmatter: `o365_id`, `serie`, `date`.
|
|
- Wenn `o365_id` leer ist: User fragen, ob Auto-Import übersprungen werden soll (dann weiter bei Schritt 4).
|
|
|
|
## Schritt 2 — Artifacts holen
|
|
|
|
Führe aus:
|
|
```
|
|
cd scripts
|
|
node fetch-meeting-artifacts.js --o365-id <o365_id> --out /tmp/artifacts.json
|
|
```
|
|
|
|
Lies das JSON. Erwartete Felder:
|
|
- `meeting.seriesMasterId`
|
|
- `transcript` (string oder null)
|
|
- `recap` (object mit `meetingNotes`, `actionItems`, `mentions` — oder null)
|
|
- `recordingUrl` (string oder null)
|
|
- `warnings` (Liste)
|
|
|
|
Wenn `transcript === null` und `recap === null`: User fragen, ob er den Recap aus Teams einfügen will, oder ob das Meeting ohne Auto-Import abgeschlossen werden soll.
|
|
|
|
## Schritt 3 — Sprecher-Mapping
|
|
|
|
Sammle alle Sprecher-Namen aus `transcript` (Zeilenanfang vor `:`).
|
|
Rufe `scripts/lib/speaker-matcher.js` (matchSpeakers) konzeptionell an: pro Name → Wikilink oder null.
|
|
Notiere ungematchte Sprecher als offene Punkte am Ende des Berichts.
|
|
|
|
## Schritt 4 — Doppel-Pass-Synthese
|
|
|
|
Lies die existierende Meeting-Notiz vollständig (Live-Notizen!).
|
|
|
|
Doppel-Pass:
|
|
- **Aus Recap** (sofern vorhanden): Topics, Entscheidungen, offene Fragen, Tasks extrahieren.
|
|
- **Aus Transkript**: pro Recap-Aussage validieren; ergänze Details, die mehrfach im Transkript bestätigt sind aber im Recap fehlen. Sprecher als Wikilinks ersetzen.
|
|
|
|
Schreibe Ergebnis in die Meeting-Notiz:
|
|
- `## Notizen` (gegliedert nach Topics, mit Wikilinks zu Sprechern)
|
|
- `## Entscheidungen`
|
|
- `## Offene Fragen`
|
|
- `## Aufgaben` (Owner als Wikilinks)
|
|
|
|
**Merge-Regel mit Live-Notizen:**
|
|
- Live-Eintrag mit gleichem Aktor + gleicher Kernaussage → Recap-Eintrag SKIP.
|
|
- Live-Eintrag stub (nur Stichwort) → durch Recap-Detail ergänzen.
|
|
- Recap-Eintrag neu → unten anhängen.
|
|
- Reihenfolge: Live-Order bleibt, neue Items hinten.
|
|
|
|
Wenn `recordingUrl` vorhanden: füge ins Frontmatter `recording_url: <url>` ein (oder ersetze existierenden Wert).
|
|
|
|
## Schritt 5 — Agenda-Rückführung mit Konfidenz
|
|
|
|
Bestimme die zugehörige Agenda:
|
|
- Aus Frontmatter `serie:` der Meeting-Notiz.
|
|
- Pfad: `03 Bereiche/Jour Fixe/<Serie>/Agenda*.md`.
|
|
- Verifiziere: `o365_series_id` der Agenda matched `meeting.seriesMasterId`. Mismatch → User warnen.
|
|
|
|
Pro Topic der Meeting-Notiz (H3-Überschriften unter `## 📌 Dauerläufer`, `## 🆕 Neu`, `## ⏸️ Postponed`):
|
|
|
|
| Signal in Notiz/Recap/Transkript | Konfidenz | Aktion |
|
|
|---|---|---|
|
|
| `## ⏸️ Postponed` enthält dieses Topic | HOCH | postpone → in Agenda `## ⏸️ Postponed` verschieben |
|
|
| Topic-Status aus Notiz oder Recap: "erledigt"/"abgeschlossen"/"✅" + alle Tasks done | HOCH | archivieren (mit ganzer Historie) |
|
|
| Topic erwähnt, kein Abschluss-Marker | HOCH | Historie-Eintrag in Agenda: `**YYYY-MM-DD:** Kurzfassung → [[Meeting-Notiz]]` |
|
|
| Topic in Agenda, aber nicht im Notiz-Block | HOCH | unverändert |
|
|
| Mehrdeutig | NIEDRIG | zur Bestätigung listen |
|
|
|
|
Sammel-Bericht VOR Schreiben:
|
|
```
|
|
Vorgesehene Aktionen:
|
|
- 5 Topics: Historie-Eintrag (auto)
|
|
- 2 Topics: archivieren (auto): X, Y
|
|
- 1 Topic: postponed (auto): Z
|
|
|
|
Bestätigung nötig:
|
|
- "NAC-Projekt": Recap mehrdeutig — erledigt oder offen?
|
|
|
|
Ungematchte Sprecher (Personen-Notiz fehlt): Maria Schmidt
|
|
```
|
|
|
|
User bestätigt einmal für HOCH-Aktionen, entscheidet pro NIEDRIG-Topic. Dann ausführen.
|
|
|
|
## Schritt 6 — Neue Topics nach Dauerläufer
|
|
|
|
Topics, die in `## 🆕 Neu` der Meeting-Notiz standen und nicht archiviert oder postponed wurden, in der Agenda nach `## 📌 Dauerläufer` verschieben (🆕-Emoji aus Titel entfernen).
|
|
|
|
## Schritt 7 — Status
|
|
|
|
In der Meeting-Notiz: `status: abgeschlossen` setzen.
|
|
|
|
## Schritt 8 — Bericht
|
|
|
|
Gib eine Zusammenfassung aus:
|
|
- Recap aus Graph: ja/nein
|
|
- Transkript: ja/nein
|
|
- Recording-Link: ja/nein
|
|
- X Topics aktualisiert, Y archiviert, Z postponed, W neu nach Dauerläufer
|
|
- Ungematchte Sprecher (falls vorhanden): Hinweis auf fehlende Personen-Notizen
|
|
- Warnings aus dem Fetch-Script (falls vorhanden)
|
|
```
|
|
|
|
- [ ] **Step 2: Verify file**
|
|
|
|
```bash
|
|
cat ".claude/commands/meeting-end.md"
|
|
```
|
|
Expected: new content above, no remnants of old version.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add ".claude/commands/meeting-end.md"
|
|
git commit -m "feat(meetings): rewrite /meeting-end with graph auto-import"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Update `.env.example` and Add Permissions Note
|
|
|
|
**Files:**
|
|
- Modify: `scripts/.env.example`
|
|
|
|
- [ ] **Step 1: Append permission notes**
|
|
|
|
Replace `scripts/.env.example` with:
|
|
|
|
```
|
|
# Azure AD App Registration (Application / Client Credentials flow)
|
|
AZURE_TENANT_ID=your-tenant-id
|
|
AZURE_CLIENT_ID=your-client-id
|
|
AZURE_CLIENT_SECRET=your-client-secret
|
|
# User whose calendar/meetings to read
|
|
AZURE_USER_EMAIL=your-email@company.com
|
|
|
|
# Required Graph application permissions (admin-consent):
|
|
# Calendars.Read
|
|
# OnlineMeetings.Read.All (for transcript/recap/recording access)
|
|
# OnlineMeetingTranscript.Read.All
|
|
# OnlineMeetingArtifact.Read.All
|
|
#
|
|
# In addition, an Application Access Policy must grant the app access to
|
|
# AZURE_USER_EMAIL's online meetings:
|
|
# New-CsApplicationAccessPolicy -Identity "obsidian-graph" -AppIds "<AZURE_CLIENT_ID>" -Description "..."
|
|
# Grant-CsApplicationAccessPolicy -PolicyName "obsidian-graph" -Identity "<AZURE_USER_EMAIL>"
|
|
#
|
|
# Beta endpoint (aiInsights) requires Teams Premium / Copilot license on the tenant.
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add scripts/.env.example
|
|
git commit -m "docs(scripts): document graph permissions for online meetings"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: End-to-End Smoke Test
|
|
|
|
This is a manual verification, not automated. Run with a real, recently-completed Jour Fixe meeting.
|
|
|
|
- [ ] **Step 1: Choose a real meeting**
|
|
|
|
Pick a Jour Fixe IT Team meeting from this week that already happened (e.g. yesterday's). Open the meeting note in Obsidian.
|
|
|
|
- [ ] **Step 2: Run fetcher in dry mode**
|
|
|
|
```bash
|
|
cd scripts
|
|
node fetch-meeting-artifacts.js --o365-id "<id-from-frontmatter>" --out /tmp/test-fetch.json
|
|
cat /tmp/test-fetch.json
|
|
```
|
|
|
|
Expected: JSON with `meeting`, `transcript` non-null, `recap` non-null (if Copilot ran), `warnings` empty or minor.
|
|
|
|
- [ ] **Step 3: Run `/meeting-end` in Claude on the same note**
|
|
|
|
Trigger the command. Verify:
|
|
- Sammel-Bericht zeigt sinnvolle Konfidenz-Klassifikation
|
|
- Nach Bestätigung: Notiz hat zusätzlich `## Notizen`, `## Entscheidungen`, `## Aufgaben` mit Sprecher-Wikilinks
|
|
- Agenda enthält neuen Historie-Eintrag mit Verweis auf die Meeting-Notiz
|
|
- Status der Meeting-Notiz: `abgeschlossen`
|
|
- `recording_url` im Frontmatter (falls Recording vorhanden)
|
|
|
|
- [ ] **Step 4: Spot-check archive**
|
|
|
|
Wenn ein Topic archiviert wurde: prüfe `Archiv*.md` der Serie — Topic vorhanden mit Datums-Heading + Historie?
|
|
|
|
- [ ] **Step 5: Document any issues**
|
|
|
|
Gefundene Schwächen → in der Spec unter neuem Abschnitt "Issues from E2E" sammeln, oder Folge-Tickets anlegen.
|
|
|
|
- [ ] **Step 6: Commit smoke test results (optional)**
|
|
|
|
Falls die Spec angepasst wurde:
|
|
|
|
```bash
|
|
git add docs/superpowers/specs/2026-05-06-meeting-end-auto-import-design.md
|
|
git commit -m "docs: e2e findings for meeting-end auto-import"
|
|
```
|
|
|
|
---
|
|
|
|
## Self-Review Notes
|
|
|
|
- Spec coverage: alle Komponenten aus Spec sind als Tasks abgebildet (vtt-parser=Task1, speaker-matcher=Task2, graph-meetings+resolveOnlineMeeting+transcript+aiInsights+recordings=Task3+5, getEventById/joinUrl=Task4, CLI=Task5, Backfill=Task6+7, command=Task8, env doc=Task9, e2e=Task10).
|
|
- Open questions from spec: Backfill scope (IT Team/LANdata) → Task 6; rest lazy → Task 7. Recordings → integriert in Task 3+5.
|
|
- No placeholders. All code blocks complete. Function/method names consistent (`runFetch`, `resolveOnlineMeeting`, `fetchTranscriptVtt`, `fetchAiInsights`, `fetchRecordingUrl`, `upsertFrontmatterField`, `parseVtt`, `formatTranscript`, `matchSpeakers`, `replaceSpeakerNames`, `extractJoinUrlFromBody`, `getEventById`).
|
|
- Confidence heuristic table is concrete, no vague "handle edge cases".
|