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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ addresses, and machine-specific workflow details.
- There is exactly ONE polish mode: a single adaptive prompt (no mode picker, no prompt profiles, no duration gates). It deletes filler and duplicated ideas, keeps every instruction/name/number, respects tone, and never converts prose into invented lists. Do not reintroduce per-mode prompts or skip gates — silent gates read as "the AI didn't work".
- The prompt is dictionary-first: keyterms plus correction targets are canonical spellings that map mishearings, are never translated, and are never injected into text that does not mention them. Benchmark prompt changes case-by-case against real dictation history on the PRODUCTION model (OpenRouter `gpt-5.4-nano`) before shipping; never tune by feel.
- Filler deletion is two-tier by evidence, not by vibe: pure fillers are always-delete, but dual-use words ("la verdad", "equis", "tal", "y ya") are contextual — real history shows they usually carry meaning ("la verdad es que…", "equis cosas"). Do not move dual-use words back into the always-delete list without a bench run proving it.
- Reasoning effort is a single global setting (`PolishReasoningEffort`, default Off) sent with every polish request — OpenRouter gets `reasoning: {effort, exclude}`, everyone else `reasoning_effort`. Off exists because reasoning models otherwise think away the output token cap and the polish arrives truncated (Mercury 2, 2026-07-05). Explicit levels add `reasoningTokenHeadroom` to the cap; a provider that rejects the parameter gets one retry without it. Do not add per-endpoint or per-model reasoning knobs.
- On endpoints that support structured outputs (OpenAI, OpenRouter) the polish uses a strict JSON schema with a leading `filler_scan` field — forcing the model to enumerate fillers before writing `polished` measurably cuts leftovers on long chunks. Groq/local/custom keep the plain-text contract, and a rejected structured request falls back to plain automatically. Never log or persist `filler_scan`.
- Long transcripts are polished in sentence-boundary chunks (`TranscriptPostProcessor.splitIntoChunks`): past ~2k characters small models under-clean or summarize, and chunking restores medium-length quality. Keep the chunk seams on sentence boundaries. Chunks 2+ receive the RAW tail of their predecessor as continuity context (raw, not polished, so hosted chunks keep running in parallel).
- Local STT engines (WhisperKit, Local AI Server) receive the vocabulary as a Whisper-style initial prompt via `VocabularyManager.initialPromptText()` — canonical forms only, never misheard variants.
Expand All @@ -44,6 +45,7 @@ addresses, and machine-specific workflow details.
- The output-language picker (Settings + overlay translation chip) is the sole source of truth for translation targets. Do not reintroduce per-prompt force-English state.
- The hard-token guard is retry-only. It may ask the model to regenerate up to 3 total attempts when URLs, emails, vocabulary, or identifier-like tokens drift. Ratio, numbers, generic capitalization, and normal rewording must not raw-fallback an AI polish. Numbers are deliberately NOT hard anchors: STT mangles spoken numbers with random separators ("0,63.40.64") and the polish must be free to repair them — number fidelity belongs to the prompt and the chunker (which never splits inside a number).
- `PolishContentDiffGuard` is the lenient complement, also retry-only: it flags digit RUNS that vanish entirely (re-punctuation and stutter absorption pass) and raw sentences whose distinctive words are almost all missing from the output (a dropped passage). It shares the same retry budget and must never raw-fallback an otherwise good polish.
- History keeps a polish trail: every APPLIED polish also inserts into `polish_versions` (schema v4; centralized in the manager's save/update paths — never insert from callers), raw text stays in `raw_transcription`, and deletes sweep orphaned versions. The history "Polish with AI" menu re-polishes with any endpoint/model recorded by `PolishProviderConfiguration.recordRecentModel` (called only when a polish APPLIES — never while typing in Settings, which fires per keystroke).
- `AIPolishMemoryManager` stores only reviewable correction suggestions; accepted corrections merge into the replacements dictionary for future polish requests.

## Private Local Workflows
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

## [2.7.0] - 2026-07-05

### Added

- **Reasoning effort control** — Settings → AI Polish now has a global "Model reasoning" picker (Auto, Off, Low, Medium, High) applied to every polish request on any endpoint. Off is the default: reasoning models (Mercury, GPT-5.x, Grok, Qwen thinking) otherwise spend the polish's output token budget thinking and the response arrives truncated. Explicit reasoning levels reserve extra output tokens for the thinking pass, providers that reject the parameter get one automatic retry without it, and non-reasoning models ignore it.
- **Polish with any configured model** — the History "Polish with AI" button now opens a model menu: every configured endpoint offers its current model plus the models that recently completed a polish on it, so a transcript can be re-polished with GPT, Mercury, the local server, etc. side by side without touching the global provider selection. Re-polishing always starts from the raw transcript.
- **Polish history per entry** — every applied polish is preserved as a version (model, time, full text) instead of being overwritten. The History detail shows the trail under "Polish history": the raw transcript, each regeneration, and the current text, each with one-click copy. Existing entries keep their latest polish as the first version.
- **Open in History from the result pill** — the completed-dictation pill has a new button that jumps straight to that dictation in the History window, already selected.

### Fixed

- The "Primary microphone" and "Test microphone" switches in Settings → General now sit flush against the card's right edge like every other switch in the window.

## [2.6.0] - 2026-07-04

### Added
Expand Down
16 changes: 8 additions & 8 deletions SapoWhisper.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -216,10 +216,10 @@
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 10;
CURRENT_PROJECT_VERSION = 11;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 2.6.0;
MARKETING_VERSION = 2.7.0;
PRODUCT_BUNDLE_IDENTIFIER = oli.SapoWhisperTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
Expand All @@ -236,10 +236,10 @@
buildSettings = {
ARCHS = arm64;
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 10;
CURRENT_PROJECT_VERSION = 11;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 2.6.0;
MARKETING_VERSION = 2.7.0;
PRODUCT_BUNDLE_IDENTIFIER = oli.SapoWhisperTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
Expand Down Expand Up @@ -385,7 +385,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = SapoWhisper/SapoWhisper.entitlements;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 10;
CURRENT_PROJECT_VERSION = 11;
DEAD_CODE_STRIPPING = YES;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
Expand All @@ -400,7 +400,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 2.6.0;
MARKETING_VERSION = 2.7.0;
PRODUCT_BUNDLE_IDENTIFIER = oli.SapoWhisper;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
Expand All @@ -424,7 +424,7 @@
CODE_SIGN_ENTITLEMENTS = SapoWhisper/SapoWhisper.entitlements;
CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 10;
CURRENT_PROJECT_VERSION = 11;
DEAD_CODE_STRIPPING = YES;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
Expand All @@ -439,7 +439,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 2.6.0;
MARKETING_VERSION = 2.7.0;
PRODUCT_BUNDLE_IDENTIFIER = oli.SapoWhisper;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
Expand Down
10 changes: 10 additions & 0 deletions SapoWhisper/App/MenuBarStatusController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ final class MenuBarStatusController: NSObject, NSPopoverDelegate {
private var popoverOpenCount = 0
private var settingsOpenCount = 0
private var historyOpenCount = 0
private var historyFocusObserver: NSObjectProtocol?

init(viewModel: SapoWhisperViewModel) {
self.viewModel = viewModel
Expand All @@ -39,6 +40,15 @@ final class MenuBarStatusController: NSObject, NSPopoverDelegate {
setupStatusItem()
setupPopover()
bindStatusImage()
// The overlay result pill lives outside any SwiftUI window scene, so
// it requests the History window through this notification.
historyFocusObserver = NotificationCenter.default.addObserver(
forName: HistoryFocusRequest.notification, object: nil, queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.openHistoryWindow()
}
}
}

func closePopover() {
Expand Down
4 changes: 4 additions & 0 deletions SapoWhisper/Core/Managers/OverlayWindowManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ class OverlayWindowManager: ObservableObject {
/// last dictation with the freshly stored language default.
var onRepolishRequested: (() -> Void)?

/// Result pill "open in History": jump straight to the entry that was
/// just dictated.
var onOpenHistoryRequested: (() -> Void)?

// MARK: - Private Properties

private var overlayWindow: RecordingOverlayWindow?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ nonisolated extension TranscriptionHistoryManager {

// Pinned rows survive the DELETE: sweep only audio that lost its row.
audioStorage.deleteOrphanedAudioFiles(referencedPaths: referencedAudioPaths())
deleteOrphanedPolishVersions()

notifyDidChange()
return deleted
Expand All @@ -48,6 +49,7 @@ nonisolated extension TranscriptionHistoryManager {
for path in paths {
audioStorage.deleteAudioFile(at: path)
}
deleteOrphanedPolishVersions()

notifyDidChange()
return deleted
Expand Down Expand Up @@ -102,6 +104,7 @@ nonisolated extension TranscriptionHistoryManager {
if let audioPath {
audioStorage.deleteAudioFile(at: audioPath)
}
deleteOrphanedPolishVersions()

notifyDidChange()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,36 @@ nonisolated extension TranscriptionHistoryManager {
return sqlite3_column_double(stmt, 0)
}

/// Polish trail of one entry, newest first.
func polishVersions(for entryId: Int64) -> [PolishVersion] {
let sql = """
SELECT id, created_at, model, text FROM polish_versions
WHERE entry_id = ? ORDER BY created_at DESC, id DESC;
"""
var stmt: OpaquePointer?
defer { sqlite3_finalize(stmt) }
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
sqlite3_bind_int64(stmt, 1, entryId)

var versions: [PolishVersion] = []
while sqlite3_step(stmt) == SQLITE_ROW {
guard let createdCString = sqlite3_column_text(stmt, 1),
let textCString = sqlite3_column_text(stmt, 3)
else { continue }
let model = sqlite3_column_text(stmt, 2).map { String(cString: $0) }
versions.append(
PolishVersion(
id: sqlite3_column_int64(stmt, 0),
entryId: entryId,
createdAt: Self.isoFormatter.date(from: String(cString: createdCString)) ?? Date(),
model: model,
text: String(cString: textCString)
)
)
}
return versions
}

func fetchEntries(
searchText: String = "",
engineFilter: EngineFilter = .all,
Expand Down
46 changes: 46 additions & 0 deletions SapoWhisper/Core/Managers/TranscriptionHistoryManager+Setup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ nonisolated extension TranscriptionHistoryManager {
version = setSchemaVersion(3) ? 3 : version
}
}

if version == 3 {
if createPolishVersionsTable(backfill: true) {
version = setSchemaVersion(4) ? 4 : version
}
}
}

func schemaVersion() -> Int32 {
Expand Down Expand Up @@ -137,6 +143,46 @@ nonisolated extension TranscriptionHistoryManager {
return true
}

/// Every applied polish of an entry, oldest first. The row's ai_* columns
/// keep only the latest polish; this table preserves the full regeneration
/// trail so re-polishing never destroys a previous result.
private func createPolishVersionsTable(backfill: Bool) -> Bool {
let createSQL = """
CREATE TABLE IF NOT EXISTS polish_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entry_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
model TEXT,
text TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_polish_versions_entry ON polish_versions(entry_id, created_at);
"""
if sqlite3_exec(db, createSQL, nil, nil, nil) != SQLITE_OK {
let message = sqlite3_errmsg(db).map { String(cString: $0) } ?? "unknown"
SapoLog.recording.error(
"failure=History/createPolishVersions detail=\(message, privacy: .public)"
)
return false
}

guard backfill else { return true }
// Existing rows only know their latest applied polish; seed it as the
// first version so old entries show a trail too.
let backfillSQL = """
INSERT INTO polish_versions(entry_id, created_at, model, text)
SELECT id, timestamp, ai_model, transcription FROM transcriptions
WHERE ai_status = 'applied' AND transcription != '';
"""
if sqlite3_exec(db, backfillSQL, nil, nil, nil) != SQLITE_OK {
let message = sqlite3_errmsg(db).map { String(cString: $0) } ?? "unknown"
SapoLog.recording.error(
"failure=History/backfillPolishVersions detail=\(message, privacy: .public)"
)
return false
}
return true
}

private func addColumnIfNeeded(named columnName: String, definition: String) {
guard !columnExists(named: columnName, in: "transcriptions") else { return }

Expand Down
36 changes: 36 additions & 0 deletions SapoWhisper/Core/Managers/TranscriptionHistoryManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -167,11 +167,41 @@ nonisolated class TranscriptionHistoryManager: @unchecked Sendable {

guard stepStatement(stmt, operation: "save") else { return -1 }
let rowID = sqlite3_last_insert_rowid(db)
if aiStatus == TranscriptAIStatus.applied.rawValue {
insertPolishVersion(entryId: rowID, model: aiModel, text: text)
}
enforceAudioStorageLimit()
notifyDidChange()
return rowID
}

/// Appends one row to the entry's polish trail. Called from every write
/// path that lands an applied polish, so the trail and the ai_* columns
/// can never drift apart.
func insertPolishVersion(entryId: Int64, model: String?, text: String) {
guard !text.isEmpty else { return }
let sql = "INSERT INTO polish_versions (entry_id, created_at, model, text) VALUES (?, ?, ?, ?);"
var stmt: OpaquePointer?
defer { sqlite3_finalize(stmt) }
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return }
sqlite3_bind_int64(stmt, 1, entryId)
bindText(stmt, 2, Self.isoFormatter.string(from: Date()))
if let model {
bindText(stmt, 3, model)
} else {
sqlite3_bind_null(stmt, 3)
}
bindText(stmt, 4, text)
stepStatement(stmt, operation: "insertPolishVersion")
}

/// polish_versions has no FK cascade (the table arrived after the schema
/// settled); every delete path sweeps rows whose entry is gone.
func deleteOrphanedPolishVersions() {
let sql = "DELETE FROM polish_versions WHERE entry_id NOT IN (SELECT id FROM transcriptions);"
sqlite3_exec(db, sql, nil, nil, nil)
}

/// Atomically copies the source audio into history storage and inserts its
/// row under `persistenceLock`. Holding the lock across copy + insert +
/// orphan sweep is what prevents a concurrent save's sweep from deleting the
Expand Down Expand Up @@ -292,6 +322,9 @@ nonisolated class TranscriptionHistoryManager: @unchecked Sendable {
}
sqlite3_bind_int64(stmt, 7, id)
stepStatement(stmt, operation: "updateAIProcessing")
if aiStatus == .applied {
insertPolishVersion(entryId: id, model: aiModel, text: finalText)
}
notifyDidChange()
}

Expand Down Expand Up @@ -343,6 +376,9 @@ nonisolated class TranscriptionHistoryManager: @unchecked Sendable {
}
sqlite3_bind_int64(stmt, 8, id)
stepStatement(stmt, operation: "updateRetranscription")
if aiStatus == .applied {
insertPolishVersion(entryId: id, model: aiModel, text: finalText)
}
notifyDidChange()
}
}
Loading
Loading