Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion crates/core/src/diarize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ pub enum Confidence {
}

/// How the attribution was determined.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum AttributionSource {
Deterministic,
Expand All @@ -139,6 +139,15 @@ pub enum AttributionSource {
MlBleedDegraded,
#[serde(rename = "stem-recovery")]
StemRecovery,
/// A provenance label this build does not know, preserved verbatim.
///
/// Hand-edited files and `minutes import text` archives are supported
/// inputs, so an unrecognized label must not make a meeting unreadable
/// (#595). The raw string round-trips untouched rather than being
/// normalized away, so provenance survives a rewrite by a command that
/// never consumed it.
#[serde(untagged)]
Unknown(String),
}

/// A mapping from an anonymous speaker label to a real person.
Expand Down Expand Up @@ -3575,6 +3584,53 @@ mod tests {
assert!(result.contains("[SPEAKER_1 0:02] [typing]"));
}

#[test]
fn unknown_attribution_source_parses_and_roundtrips_verbatim() {
// #595: `user-confirmed` is not a value Minutes emits, but the schema
// doc's L3 label invited it and hand-edited files are a supported input.
// An unrecognized provenance label must not make the document
// unparseable, and must not be silently normalized on write-back.
let yaml =
"speaker_label: SPEAKER_1\nname: Alice\nconfidence: high\nsource: user-confirmed\n";
let parsed: SpeakerAttribution =
serde_yaml::from_str(yaml).expect("an unknown source must not abort the parse");

assert_eq!(
parsed.source,
AttributionSource::Unknown("user-confirmed".into())
);

let rewritten = serde_yaml::to_string(&parsed).unwrap();
assert!(
rewritten.contains("user-confirmed"),
"the original label must survive a rewrite, got: {rewritten}"
);
assert!(
!rewritten.contains("unknown"),
"the label must not be normalized away, got: {rewritten}"
);
}

#[test]
fn known_attribution_sources_still_parse_strictly() {
// The lenient variant must not swallow the real vocabulary.
for (raw, expected) in [
("deterministic", AttributionSource::Deterministic),
("llm", AttributionSource::Llm),
("enrollment", AttributionSource::Enrollment),
("manual", AttributionSource::Manual),
("ml-bleed-degraded", AttributionSource::MlBleedDegraded),
("stem-recovery", AttributionSource::StemRecovery),
] {
let yaml = format!("speaker_label: S1\nname: A\nconfidence: high\nsource: {raw}\n");
let parsed: SpeakerAttribution = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(
parsed.source, expected,
"{raw} must not fall through to Unknown"
);
}
}

#[test]
fn speaker_attribution_roundtrips_yaml() {
let attr = SpeakerAttribution {
Expand Down
8 changes: 5 additions & 3 deletions crates/core/src/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ impl SelfAttributionOutcome {
speaker_label: Some(attribution.speaker_label.clone()),
name: Some(attribution.name.clone()),
confidence: Some(confidence_label(attribution.confidence)),
source: Some(attribution_source_label(attribution.source)),
source: Some(attribution_source_label(&attribution.source)),
applied_via: Some(applied_via),
skipped_reason: None,
fallback_reason,
Expand Down Expand Up @@ -445,14 +445,16 @@ fn confidence_label(confidence: diarize::Confidence) -> String {
}
}

fn attribution_source_label(source: diarize::AttributionSource) -> String {
fn attribution_source_label(source: &diarize::AttributionSource) -> String {
match source {
diarize::AttributionSource::Deterministic => "deterministic".into(),
diarize::AttributionSource::Llm => "llm".into(),
diarize::AttributionSource::Enrollment => "enrollment".into(),
diarize::AttributionSource::Manual => "manual".into(),
diarize::AttributionSource::MlBleedDegraded => "ml-bleed-degraded".into(),
diarize::AttributionSource::StemRecovery => "stem-recovery".into(),
// Round-trip an unrecognized label verbatim rather than normalizing it (#595).
diarize::AttributionSource::Unknown(raw) => raw.clone(),
}
}

Expand Down Expand Up @@ -501,7 +503,7 @@ fn debug_speaker_map(speaker_map: &[diarize::SpeakerAttribution]) -> Vec<Speaker
speaker_label: entry.speaker_label.clone(),
name: entry.name.clone(),
confidence: confidence_label(entry.confidence),
source: attribution_source_label(entry.source),
source: attribution_source_label(&entry.source),
})
.collect()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
---
source: crates/core/src/markdown.rs
assertion_line: 1586
expression: schema
---
{
Expand Down Expand Up @@ -269,14 +268,22 @@ expression: schema
},
"AttributionSource": {
"description": "How the attribution was determined.",
"type": "string",
"enum": [
"deterministic",
"llm",
"enrollment",
"manual",
"ml-bleed-degraded",
"stem-recovery"
"anyOf": [
{
"type": "string",
"enum": [
"deterministic",
"llm",
"enrollment",
"manual",
"ml-bleed-degraded",
"stem-recovery"
]
},
{
"description": "A provenance label this build does not know, preserved verbatim.\n\nHand-edited files and `minutes import text` archives are supported\ninputs, so an unrecognized label must not make a meeting unreadable\n(#595). The raw string round-trips untouched rather than being\nnormalized away, so provenance survives a rewrite by a command that\nnever consumed it.",
"type": "string"
}
]
},
"CapturePolicy": {
Expand Down
11 changes: 10 additions & 1 deletion crates/reader/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ pub enum Confidence {
Low,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum AttributionSource {
Deterministic,
Expand All @@ -203,6 +203,15 @@ pub enum AttributionSource {
MlBleedDegraded,
#[serde(rename = "stem-recovery")]
StemRecovery,
/// A provenance label this build does not know, preserved verbatim.
///
/// Hand-edited files and `minutes import text` archives are supported
/// inputs, so an unrecognized label must not make a meeting unreadable
/// (#595). The raw string round-trips untouched rather than being
/// normalized away, so provenance survives a rewrite by a command that
/// never consumed it.
#[serde(untagged)]
Unknown(String),
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
Expand Down
13 changes: 12 additions & 1 deletion crates/sdk/src/reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,24 @@ export interface SpeakerAttribution {
source: AttributionSource;
}

/**
* How an attribution was derived.
*
* The named values are what Minutes emits. The trailing `(string & {})` keeps
* an unrecognized label readable instead of a type error: hand-edited files and
* `minutes import text` archives are supported inputs, so a provenance label
* must never make a meeting unparseable (#595). Mirrors the `Unknown(String)`
* variant on the Rust side, which round-trips the raw value verbatim.
*/
export type AttributionSource =
| "deterministic"
| "llm"
| "enrollment"
| "manual"
| "ml-bleed-degraded"
| "stem-recovery";
| "stem-recovery"
// eslint-disable-next-line @typescript-eslint/ban-types
| (string & {});

export type DiagnosticConfidence = "high" | "inferred";
export type CaptureSource = "voice" | "system" | "both" | "backend";
Expand Down
26 changes: 16 additions & 10 deletions docs/architecture/frontmatter-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,16 +187,22 @@ Each entry has four fields:
| `source` | enum | ✓ | `deterministic` / `llm` / `enrollment` / `manual` / `ml-bleed-degraded` / `stem-recovery`. Tells consumers how the attribution was derived. |

Produced by Minutes' diarization + attribution pipeline. Four confidence
levels (L0-L3):

- **L0** (deterministic): inferred from calendar attendee + user identity with
no ambiguity. Highest trust.
- **L1** (LLM-suggested): proposed by an LLM from context. Capped at `medium`
confidence by design, since wrong names are worse than anonymous.
- **L2** (voice-enrolled): matched against a stored voice profile in
`~/.minutes/voices.db`. High when the match is strong.
- **L3** (user-confirmed): the user explicitly confirmed this attribution.
Highest trust once set.
levels (L0-L3).

**The L-levels below are a conceptual ladder, not the on-disk vocabulary.** The
only values valid in `source` are the six in the table above. The parenthetical
names here describe the level; where one differs from the on-disk value, the
`source:` value is given explicitly.

- **L0** (deterministic, `source: deterministic`): inferred from calendar
attendee + user identity with no ambiguity. Highest trust.
- **L1** (LLM-suggested, `source: llm`): proposed by an LLM from context. Capped
at `medium` confidence by design, since wrong names are worse than anonymous.
- **L2** (voice-enrolled, `source: enrollment`): matched against a stored voice
profile in `~/.minutes/voices.db`. High when the match is strong.
- **L3** (user-confirmed, `source: manual`): the user explicitly confirmed this
attribution. Highest trust once set. Note the on-disk value is `manual`;
`user-confirmed` is not a valid value and Minutes never writes it (#595).
- **Degraded-capture recovery** (`ml-bleed-degraded`): ML diarization over a
voice stem after the primary system-audio stem was degraded. Confidence is
normally low until a user confirms the attribution.
Expand Down
2 changes: 1 addition & 1 deletion site/lib/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const MINUTES_RELEASE_TAG = `v${MINUTES_RELEASE_VERSION}`;

export const MINUTES_MCP_TOOL_COUNT = 37;
export const MINUTES_CLI_COMMAND_COUNT = 58;
export const MINUTES_TEST_COUNT = 1673;
export const MINUTES_TEST_COUNT = 1675;

export const APPLE_SILICON_DMG =
`https://github.com/silverstein/minutes/releases/download/${MINUTES_RELEASE_TAG}/Minutes_${MINUTES_RELEASE_VERSION}_aarch64.dmg`;
Expand Down
2 changes: 1 addition & 1 deletion tauri/src-tauri/gen/schemas/acl-manifests.json

Large diffs are not rendered by default.

Loading