fix: deepen opens the linked note, task list columns in "Alle Aufgaben", Whisper repeats, date field
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m33s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m33s
- The deepen buttons of task rows and Kanban cards open an already linked note (accent colour) instead of creating another one; the server refuses a second note for a linked task as well. - "Alle Aufgaben" uses each task's own list columns for the status selector, dot and detail. - Whisper: send vad_filter (STT_VAD_FILTER, default on) and drop a verbatim repeated tail of sentences or words from the transcript. - Task detail: narrower time field, wider date field in the desktop sidebar. v0.37.2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c56bd4d943
commit
6f2eb08489
13 files changed
+131
-18
No files matched your search
+60
-1
@@ -12,6 +12,7 @@ import (
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -173,6 +174,9 @@ func (s *Server) transcribe(ctx context.Context, audio []byte, filename, content
|
||||
_ = mw.WriteField("language", lang)
|
||||
}
|
||||
_ = mw.WriteField("response_format", "json")
|
||||
if s.cfg.Speech.VADFilter {
|
||||
_ = mw.WriteField("vad_filter", "true")
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -192,7 +196,62 @@ func (s *Server) transcribe(ctx context.Context, audio []byte, filename, content
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", fmt.Errorf("unerwartete Antwort: %s", strings.TrimSpace(string(raw)))
|
||||
}
|
||||
return strings.TrimSpace(out.Text), nil
|
||||
return dropRepeatedTail(strings.TrimSpace(out.Text)), nil
|
||||
}
|
||||
|
||||
var sentenceRe = regexp.MustCompile(`[^.!?…]+[.!?…]*`)
|
||||
|
||||
func normalizeSpeech(s string) string {
|
||||
return strings.Trim(strings.ToLower(strings.Join(strings.Fields(s), " ")), " .,!?…;:-–")
|
||||
}
|
||||
|
||||
// dropRepeatedTail removes what Whisper sometimes makes up at the end of longer recordings: sentences
|
||||
// it already wrote, repeated once more, or the last words echoed right after themselves.
|
||||
func dropRepeatedTail(text string) string {
|
||||
for {
|
||||
trimmed := dropOnce(text)
|
||||
if trimmed == text {
|
||||
return text
|
||||
}
|
||||
text = trimmed
|
||||
}
|
||||
}
|
||||
|
||||
func dropOnce(text string) string {
|
||||
// Sentence level: the last k sentences occur in the same order earlier on
|
||||
sentences := sentenceRe.FindAllStringIndex(text, -1)
|
||||
norm := make([]string, len(sentences))
|
||||
for i, loc := range sentences {
|
||||
norm[i] = normalizeSpeech(text[loc[0]:loc[1]])
|
||||
}
|
||||
for k := len(norm) / 2; k >= 1; k-- {
|
||||
tail := norm[len(norm)-k:]
|
||||
if len(strings.Join(tail, " ")) < 15 {
|
||||
continue
|
||||
}
|
||||
for start := 0; start+k <= len(norm)-k; start++ {
|
||||
same := true
|
||||
for j := 0; j < k; j++ {
|
||||
if norm[start+j] != tail[j] || tail[j] == "" {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if same {
|
||||
return strings.TrimSpace(text[:sentences[len(norm)-k][0]])
|
||||
}
|
||||
}
|
||||
}
|
||||
// Word level: the last n words (at least 6) directly repeat the n words before them
|
||||
words := strings.Fields(text)
|
||||
for n := len(words) / 2; n >= 6; n-- {
|
||||
a := normalizeSpeech(strings.Join(words[len(words)-2*n:len(words)-n], " "))
|
||||
b := normalizeSpeech(strings.Join(words[len(words)-n:], " "))
|
||||
if a == b {
|
||||
return strings.Join(words[:len(words)-n], " ")
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// checkVoice asks the Whisper server whether it is up and has the model.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
// Whisper sometimes repeats earlier sentences or the last words at the end of a longer recording.
|
||||
func TestDropRepeatedTail(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want string }{
|
||||
{"Morgen den Filter wechseln. Dann die Heizung prüfen. Morgen den Filter wechseln.", "Morgen den Filter wechseln. Dann die Heizung prüfen."},
|
||||
{"Erst einkaufen. Dann kochen. Danach abwaschen. Dann kochen. Danach abwaschen.", "Erst einkaufen. Dann kochen. Danach abwaschen."},
|
||||
{"bitte die neue Version auf dem Server einspielen bitte die neue Version auf dem Server einspielen", "bitte die neue Version auf dem Server einspielen"},
|
||||
// Nothing repeated, or only a short word: unchanged
|
||||
{"Ja. Nein. Ja.", "Ja. Nein. Ja."},
|
||||
{"Kauf Milch, Brot und Eier. Und Butter.", "Kauf Milch, Brot und Eier. Und Butter."},
|
||||
{"", ""},
|
||||
} {
|
||||
if got := dropRepeatedTail(tc.in); got != tc.want {
|
||||
t.Errorf("dropRepeatedTail(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,7 @@ type SpeechConfig struct {
|
||||
MaxParallel int // transcriptions running at the same time; more wait in line
|
||||
PerUserPerHour int
|
||||
TimeoutSeconds int
|
||||
VADFilter bool // ask the Whisper server to skip silence (fewer made-up repetitions); speaches understands it
|
||||
}
|
||||
|
||||
// Mode is "server", "browser" or "off". The demo never uses the server, so visitors cannot keep
|
||||
@@ -270,6 +271,7 @@ func Load() *Config {
|
||||
MaxParallel: envInt("STT_MAX_PARALLEL", 1, 1, 32),
|
||||
PerUserPerHour: envInt("STT_PER_USER_PER_HOUR", 60, 1, 100000),
|
||||
TimeoutSeconds: envInt("STT_TIMEOUT_SECONDS", 120, 5, 3600),
|
||||
VADFilter: strings.ToLower(getEnv("STT_VAD_FILTER", "true")) != "false",
|
||||
},
|
||||
NotifyDigestMinutes: envInt("NOTIFY_DIGEST_MINUTES", 5, 1, 1440),
|
||||
VAPIDPublicKey: strings.TrimSpace(os.Getenv("VAPID_PUBLIC_KEY")),
|
||||
|
||||
@@ -311,6 +311,12 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
|
||||
convertedType = "task"
|
||||
} else if convertTo == "document" {
|
||||
isTask := thought.Status == domain.StatusKept || (thought.Status == domain.StatusConverted && thought.ConvertedType == "task")
|
||||
// A task already linked to a note keeps that note; no second one
|
||||
if isTask && thought.ConvertedType == "document_block" && thought.DocID != "" {
|
||||
if doc, err := s.GetDocumentByID(userID, thought.DocID); err == nil && doc != nil {
|
||||
return thought, nil
|
||||
}
|
||||
}
|
||||
newStatus = domain.StatusConverted
|
||||
convertedType = "document"
|
||||
title := thought.Content
|
||||
|
||||
Reference in new issue
Block a user