From 05f0ea67e2825f2bc9e5819f188057dc5ccc3b25 Mon Sep 17 00:00:00 2001 From: Charles Kelsoe Date: Thu, 9 Jul 2026 19:43:46 -0400 Subject: [PATCH] Release 0.30.0: dedupe plaud-category, add plaud-industry (#65) Follow-up to #61: suppress plaud-category when it duplicates plaud-template, capture Plaud industry_category as a separate plaud-industry property. Closes #61. --- CHANGELOG.md | 10 ++++++ __tests__/note-writer.test.ts | 50 ++++++++++++++++++++++++++++-- __tests__/summary-metadata.test.ts | 40 ++++++++++++++++++++++++ main.ts | 2 ++ manifest.json | 2 +- note-writer.ts | 18 ++++++++++- package-lock.json | 4 +-- package.json | 2 +- plaud-client-re.ts | 21 ++++++++++--- plaud-client.ts | 7 +++++ versions.json | 3 +- 11 files changed, 146 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7859035..75fbb94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to Plaud Importer will be documented in this file. ## [Unreleased] +## [0.30.0] - 2026-07-09 + +### Added + +- **New `plaud-industry` frontmatter property.** Plaud carries a topical/industry classification alongside each summary, separate from the summary category. When present it is now written as its own `plaud-industry` property instead of being folded into `plaud-category`, so the two never mix. It is left off for recordings where Plaud provides no industry value. A matching `{{industry}}` token is available in the extra-frontmatter value field. + +### Fixed + +- **The `plaud-category` property no longer duplicates `plaud-template`.** Plaud's current summary format stores the template (summary-type) name in the same field the plugin reads for the category, so both properties came out with the identical value, for example both showing "Deep Summary Transcript". The plugin now omits `plaud-category` when its value would only repeat `plaud-template`. If Plaud ever provides a category that genuinely differs from the template, it is written as before. + ## [0.29.0] - 2026-07-09 ### Fixed diff --git a/__tests__/note-writer.test.ts b/__tests__/note-writer.test.ts index f49dc2d..3d4c967 100644 --- a/__tests__/note-writer.test.ts +++ b/__tests__/note-writer.test.ts @@ -1282,8 +1282,9 @@ describe('formatFrontmatter', () => { makeSummary({ headline: 'Q2 Planning', category: 'ai-meeting', + industry: 'Technology', language: 'en', - template: 'ai-meeting', + template: 'Meeting Minutes', model: 'azure-sweden-central-gpt-5', noteId: 'note-abc', summaryId: 'sum-xyz', @@ -1293,17 +1294,60 @@ describe('formatFrontmatter', () => { // yamlScalar leaves `Q2 Planning` unquoted because the pattern // allows letters / digits / spaces. `azure-sweden-central-gpt-5` // is also pattern-safe (hyphens allowed). `"3"` gets quoted only - // because it leads with a digit. + // because it leads with a digit. category differs from template here, + // so it is not deduped (see the dedup test below). expect(fm).toContain('plaud-headline: Q2 Planning'); expect(fm).toContain('plaud-category: ai-meeting'); + expect(fm).toContain('plaud-industry: Technology'); expect(fm).toContain('plaud-language: en'); - expect(fm).toContain('plaud-template: ai-meeting'); + expect(fm).toContain('plaud-template: Meeting Minutes'); expect(fm).toContain('plaud-model: azure-sweden-central-gpt-5'); expect(fm).toContain('plaud-note-id: note-abc'); expect(fm).toContain('plaud-summary-id: sum-xyz'); expect(fm).toContain('plaud-summary-version: "3"'); }); + it('omits plaud-category when it duplicates plaud-template (issue #61)', () => { + const fm = formatFrontmatter( + makeRecording(), + [], + makeSummary({ + category: 'Deep Summary Transcript', + template: 'Deep Summary Transcript', + }), + ); + // Plaud's category field now mirrors the template name; the duplicate + // category line is suppressed while the template line stays. + expect(fm).not.toMatch(/plaud-category:/); + expect(fm).toContain('plaud-template: Deep Summary Transcript'); + }); + + it('keeps plaud-category when it differs from plaud-template', () => { + const fm = formatFrontmatter( + makeRecording(), + [], + makeSummary({ category: 'Legal', template: 'Deep Summary Transcript' }), + ); + expect(fm).toContain('plaud-category: Legal'); + expect(fm).toContain('plaud-template: Deep Summary Transcript'); + }); + + it('emits plaud-industry from the industry field, separate from category', () => { + const fm = formatFrontmatter( + makeRecording(), + [], + makeSummary({ + category: 'Deep Summary Transcript', + industry: 'Healthcare', + template: 'Deep Summary Transcript', + }), + ); + // category duplicates template and is dropped, but the real topical + // classification survives as its own property. + expect(fm).not.toMatch(/plaud-category:/); + expect(fm).toContain('plaud-industry: Healthcare'); + }); + it('emits partial extras and omits the rest', () => { const fm = formatFrontmatter( makeRecording(), diff --git a/__tests__/summary-metadata.test.ts b/__tests__/summary-metadata.test.ts index 73e36bf..b0784df 100644 --- a/__tests__/summary-metadata.test.ts +++ b/__tests__/summary-metadata.test.ts @@ -71,6 +71,7 @@ describe('findSummaryMetadata (issue #61 — newer summary path)', () => { model: 'gpt-5.5', headline: 'Example headline for the recording', category: 'Adaptive Summary', + industry: 'redacted-industry', summaryId: '20260708183602-v2@redacted', }); }); @@ -87,6 +88,45 @@ describe('findSummaryMetadata (issue #61 — newer summary path)', () => { ); }); + it('resolves industry_category separately from category, even when category mirrors the template name', () => { + const distinct = { + data: { + content_list: [ + { + data_type: 'auto_sum_note', + extra: { used_template: { template_name: 'Deep Summary Transcript' } }, + }, + ], + extra_data: { + model: 'gpt-5.5', + aiContentHeader: { + category: 'Deep Summary Transcript', + industry_category: 'Healthcare', + }, + }, + }, + }; + const md = findSummaryMetadata(distinct, endpoint); + expect(md.template).toBe('Deep Summary Transcript'); + expect(md.category).toBe('Deep Summary Transcript'); + expect(md.industry).toBe('Healthcare'); + }); + + it('omits industry when aiContentHeader has no industry_category', () => { + const noIndustry = { + data: { + content_list: [ + { data_type: 'auto_sum_note', extra: { summ_type: 'meeting' } }, + ], + extra_data: { + model: 'gpt-5.5', + aiContentHeader: { headline: 'H', category: 'Meeting' }, + }, + }, + }; + expect(findSummaryMetadata(noIndustry, endpoint).industry).toBeUndefined(); + }); + it('does not bleed category into a nested per-question category when the header lacks a direct one', () => { const noDirectCategory = { data: { diff --git a/main.ts b/main.ts index cc33566..0114a2d 100644 --- a/main.ts +++ b/main.ts @@ -325,6 +325,7 @@ const FOLDER_INSERT_TOKEN: readonly [string, string] = ["Folder", "{{plaud-folde const CONTENT_INSERT_TOKENS: ReadonlyArray = [ ["Duration", "{{duration}}"], ["Category", "{{category}}"], + ["Industry", "{{industry}}"], ["Headline", "{{headline}}"], ["Language", "{{language}}"], ["Summary template", "{{template}}"], @@ -343,6 +344,7 @@ const CUSTOM_FRONTMATTER_TOKENS: ReadonlyArray = [ ["{{plaud-folder}}", "the recording's Plaud folder name"], ["{{duration}}", "the recording length, for example 30m"], ["{{category}}", "the summary's category (empty with no AI summary)"], + ["{{industry}}", "the summary's industry or topic (empty with no AI summary)"], ["{{headline}}", "the summary's one-line headline"], ["{{YYYY}} {{MM}} {{DD}} {{Q}} {{WW}}", "the date set, same as the other fields"], ]; diff --git a/manifest.json b/manifest.json index d3e043e..37298f9 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "plaud-importer", "name": "Plaud Importer", - "version": "0.29.0", + "version": "0.30.0", "minAppVersion": "1.11.4", "description": "Import meeting summaries, transcripts, and attachments from Plaud.AI into your vault.", "author": "Charles Kelsoe (ckelsoe)", diff --git a/note-writer.ts b/note-writer.ts index eb83a0f..d58b737 100644 --- a/note-writer.ts +++ b/note-writer.ts @@ -1220,6 +1220,7 @@ export const RESERVED_FRONTMATTER_KEYS: ReadonlySet = new Set([ 'plaud-version-ms', 'plaud-headline', 'plaud-category', + 'plaud-industry', 'plaud-language', 'plaud-template', 'plaud-model', @@ -1245,6 +1246,7 @@ export interface CustomFrontmatterContext { readonly folderName: string; readonly durationSeconds: number; readonly category: string; + readonly industry: string; readonly headline: string; readonly language: string; readonly template: string; @@ -1265,6 +1267,7 @@ export function customFrontmatterContext( ? Math.max(0, Math.floor(recording.durationSeconds)) : 0, category: summary?.category ?? '', + industry: summary?.industry ?? '', headline: summary?.headline ?? '', language: summary?.language ?? '', template: summary?.template ?? '', @@ -1288,6 +1291,7 @@ export function expandCustomFrontmatterValue( const content: Record = { duration: formatDurationHoursMinutes(ctx.durationSeconds), category: ctx.category, + industry: ctx.industry, headline: ctx.headline, language: ctx.language, template: ctx.template, @@ -1328,6 +1332,7 @@ export const TEMPLATE_PREVIEW_CUSTOM_CONTEXT: CustomFrontmatterContext = { folderName: TEMPLATE_PREVIEW_FOLDER, durationSeconds: 1830, category: 'Meeting', + industry: 'Technology', headline: 'Weekly team sync recap', language: 'en', template: 'Meeting notes', @@ -1445,9 +1450,20 @@ export function formatFrontmatter( // Plaud shape that drops one of these fields. Add new known extras // here without changing any other call site. if (summary) { + // Plaud's `category` field mirrors the template/summary-type name in the + // current API shape (issue #61), so emitting both yields an identical + // duplicate. Suppress plaud-category when it exactly equals the template; + // a genuinely distinct category (or a future Plaud divergence) still comes + // through. Plaud's real topical classification lives in `industry` and is + // emitted separately as plaud-industry, never mixed into category. + const category = + summary.category !== undefined && summary.category === summary.template + ? undefined + : summary.category; const extras: ReadonlyArray = [ ['plaud-headline', summary.headline], - ['plaud-category', summary.category], + ['plaud-category', category], + ['plaud-industry', summary.industry], ['plaud-language', summary.language], ['plaud-template', summary.template], ['plaud-model', summary.model], diff --git a/package-lock.json b/package-lock.json index f6b66eb..bc9967d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-plaud-importer", - "version": "0.29.0", + "version": "0.30.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-plaud-importer", - "version": "0.29.0", + "version": "0.30.0", "license": "MIT", "devDependencies": { "@eslint/js": "^9.39.2", diff --git a/package.json b/package.json index 4c3a313..7b97297 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-plaud-importer", - "version": "0.29.0", + "version": "0.30.0", "description": "Import meeting summaries, transcripts, and attachments from Plaud.AI into an Obsidian vault.", "main": "main.js", "scripts": { diff --git a/plaud-client-re.ts b/plaud-client-re.ts index fba9f39..1ab0d9c 100644 --- a/plaud-client-re.ts +++ b/plaud-client-re.ts @@ -1791,13 +1791,21 @@ export function findAiKeywords( /** * The optional {@link Summary} fields Plaud carries as metadata around a - * generated summary. Doubles as the checklist the bundle fetch uses to - * detect shape drift: a summary present with every one of these unresolved - * is the drift signal (issue #61). + * generated summary. The subset that must resolve on every AI summary is the + * shape-drift checklist, tracked separately in {@link DRIFT_SIGNAL_FIELDS}; + * `language` and `industry` are deliberately excluded there because Plaud + * legitimately omits them, so their absence is never treated as drift (issue + * #61). */ export type SummaryMetadata = Pick< Summary, - 'headline' | 'category' | 'language' | 'template' | 'model' | 'summaryId' + | 'headline' + | 'category' + | 'industry' + | 'language' + | 'template' + | 'model' + | 'summaryId' >; // Fields expected on every AI-generated summary. When a summary IS present, @@ -1963,12 +1971,17 @@ export function findSummaryMetadata( const model = resolveString(modelScope, ['model'], 0); const headline = resolveString(headerScope, ['headline'], 0); const category = resolveString(headerScope, ['category'], 0); + // `industry_category` is Plaud's real topical classification, kept separate + // from `category` (which now mirrors the template name). Same direct-only + // scope as `category` so it can't bleed into an unrelated nested key. + const industry = resolveString(headerScope, ['industry_category'], 0); const summaryId = resolveString(idScope, ['summary_id'], 0); const language = resolveString(modelScope, ['language'], 0); return { ...(headline !== undefined && { headline }), ...(category !== undefined && { category }), + ...(industry !== undefined && { industry }), ...(language !== undefined && { language }), ...(template !== undefined && { template }), ...(model !== undefined && { model }), diff --git a/plaud-client.ts b/plaud-client.ts index fc45019..8649c66 100644 --- a/plaud-client.ts +++ b/plaud-client.ts @@ -273,6 +273,13 @@ export interface Summary { */ readonly headline?: string; readonly category?: string; + /** + * Plaud's `industry_category`: a topical/industry classification distinct + * from `category` (which in the current Plaud shape mirrors the template + * name). Surfaced as its own `plaud-industry` property rather than folded + * into `category` so the two never mix. Best-effort like the rest here. + */ + readonly industry?: string; readonly language?: string; readonly template?: string; readonly model?: string; diff --git a/versions.json b/versions.json index af6c302..633f9d4 100644 --- a/versions.json +++ b/versions.json @@ -52,5 +52,6 @@ "0.27.1": "1.11.4", "0.27.2": "1.11.4", "0.28.0": "1.11.4", - "0.29.0": "1.11.4" + "0.29.0": "1.11.4", + "0.30.0": "1.11.4" } \ No newline at end of file