kash-shell 0.3.9__py3-none-any.whl → 0.3.10__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kash/actions/__init__.py +4 -4
- kash/actions/core/markdownify.py +5 -2
- kash/actions/core/readability.py +5 -2
- kash/actions/core/render_as_html.py +18 -0
- kash/actions/core/webpage_config.py +12 -4
- kash/commands/__init__.py +8 -20
- kash/commands/base/basic_file_commands.py +15 -0
- kash/commands/base/debug_commands.py +13 -0
- kash/commands/base/general_commands.py +21 -16
- kash/commands/base/logs_commands.py +4 -2
- kash/commands/base/model_commands.py +8 -8
- kash/commands/base/search_command.py +3 -2
- kash/commands/base/show_command.py +5 -3
- kash/commands/extras/parse_uv_lock.py +186 -0
- kash/commands/help/doc_commands.py +2 -31
- kash/commands/help/welcome.py +33 -0
- kash/commands/workspace/selection_commands.py +11 -6
- kash/commands/workspace/workspace_commands.py +18 -15
- kash/config/colors.py +2 -0
- kash/config/env_settings.py +14 -1
- kash/config/init.py +2 -2
- kash/config/logger.py +59 -56
- kash/config/logger_basic.py +3 -3
- kash/config/settings.py +116 -57
- kash/config/setup.py +28 -12
- kash/config/text_styles.py +3 -13
- kash/docs/load_api_docs.py +2 -1
- kash/docs/markdown/topics/a3_getting_started.md +3 -2
- kash/{concepts → embeddings}/text_similarity.py +2 -2
- kash/exec/__init__.py +20 -3
- kash/exec/action_decorators.py +18 -4
- kash/exec/action_exec.py +41 -23
- kash/exec/action_registry.py +13 -48
- kash/exec/command_registry.py +2 -1
- kash/exec/fetch_url_metadata.py +4 -6
- kash/exec/importing.py +56 -0
- kash/exec/llm_transforms.py +6 -7
- kash/exec/precondition_registry.py +2 -1
- kash/exec/preconditions.py +16 -1
- kash/exec/shell_callable_action.py +33 -19
- kash/file_storage/file_store.py +23 -10
- kash/file_storage/item_file_format.py +5 -2
- kash/file_storage/metadata_dirs.py +11 -2
- kash/help/assistant.py +1 -1
- kash/help/assistant_instructions.py +2 -1
- kash/help/help_embeddings.py +2 -2
- kash/help/help_printing.py +7 -11
- kash/llm_utils/clean_headings.py +1 -1
- kash/llm_utils/llm_api_keys.py +4 -4
- kash/llm_utils/llm_features.py +68 -0
- kash/llm_utils/llm_messages.py +1 -2
- kash/llm_utils/llm_names.py +1 -1
- kash/llm_utils/llms.py +8 -3
- kash/local_server/__init__.py +5 -2
- kash/local_server/local_server.py +8 -5
- kash/local_server/local_server_commands.py +2 -2
- kash/local_server/local_url_formatters.py +1 -1
- kash/mcp/__init__.py +5 -2
- kash/mcp/mcp_cli.py +5 -5
- kash/mcp/mcp_server_commands.py +5 -5
- kash/mcp/mcp_server_routes.py +5 -5
- kash/mcp/mcp_server_sse.py +4 -2
- kash/media_base/media_cache.py +8 -8
- kash/media_base/media_services.py +1 -1
- kash/media_base/media_tools.py +6 -6
- kash/media_base/services/local_file_media.py +2 -2
- kash/media_base/{speech_transcription.py → transcription_deepgram.py} +25 -110
- kash/media_base/transcription_format.py +73 -0
- kash/media_base/transcription_whisper.py +38 -0
- kash/model/__init__.py +73 -5
- kash/model/actions_model.py +38 -4
- kash/model/concept_model.py +30 -0
- kash/model/items_model.py +44 -7
- kash/model/params_model.py +24 -0
- kash/shell/completions/completion_scoring.py +37 -5
- kash/shell/output/kerm_codes.py +1 -2
- kash/shell/output/shell_formatting.py +14 -4
- kash/shell/shell_main.py +2 -2
- kash/shell/utils/exception_printing.py +6 -0
- kash/shell/utils/native_utils.py +26 -20
- kash/text_handling/custom_sliding_transforms.py +12 -4
- kash/text_handling/doc_normalization.py +6 -2
- kash/text_handling/markdown_render.py +117 -0
- kash/text_handling/markdown_utils.py +204 -0
- kash/utils/common/import_utils.py +12 -3
- kash/utils/common/type_utils.py +0 -29
- kash/utils/common/url.py +27 -3
- kash/utils/errors.py +6 -0
- kash/utils/file_utils/file_formats.py +2 -2
- kash/utils/file_utils/file_formats_model.py +3 -0
- kash/web_content/dir_store.py +1 -2
- kash/web_content/file_cache_utils.py +37 -10
- kash/web_content/file_processing.py +68 -0
- kash/web_content/local_file_cache.py +12 -9
- kash/web_content/web_extract.py +8 -3
- kash/web_content/web_fetch.py +12 -4
- kash/web_gen/tabbed_webpage.py +5 -2
- kash/web_gen/templates/base_styles.css.jinja +120 -14
- kash/web_gen/templates/base_webpage.html.jinja +60 -13
- kash/web_gen/templates/content_styles.css.jinja +4 -2
- kash/web_gen/templates/item_view.html.jinja +2 -2
- kash/web_gen/templates/tabbed_webpage.html.jinja +1 -2
- kash/workspaces/__init__.py +15 -2
- kash/workspaces/selections.py +18 -3
- kash/workspaces/source_items.py +0 -1
- kash/workspaces/workspaces.py +5 -11
- kash/xonsh_custom/command_nl_utils.py +40 -19
- kash/xonsh_custom/custom_shell.py +43 -11
- kash/xonsh_custom/customize_prompt.py +39 -21
- kash/xonsh_custom/load_into_xonsh.py +22 -25
- kash/xonsh_custom/shell_load_commands.py +2 -2
- kash/xonsh_custom/xonsh_completers.py +2 -249
- kash/xonsh_custom/xonsh_keybindings.py +282 -0
- kash/xonsh_custom/xonsh_modern_tools.py +3 -3
- kash/xontrib/kash_extension.py +5 -6
- {kash_shell-0.3.9.dist-info → kash_shell-0.3.10.dist-info}/METADATA +8 -6
- {kash_shell-0.3.9.dist-info → kash_shell-0.3.10.dist-info}/RECORD +122 -123
- kash/concepts/concept_formats.py +0 -23
- kash/shell/clideps/api_keys.py +0 -100
- kash/shell/clideps/dotenv_setup.py +0 -115
- kash/shell/clideps/dotenv_utils.py +0 -98
- kash/shell/clideps/pkg_deps.py +0 -257
- kash/shell/clideps/platforms.py +0 -11
- kash/shell/clideps/terminal_features.py +0 -56
- kash/shell/utils/osc_utils.py +0 -95
- kash/shell/utils/terminal_images.py +0 -133
- kash/text_handling/markdown_util.py +0 -167
- kash/utils/common/atomic_var.py +0 -171
- kash/utils/common/string_replace.py +0 -93
- kash/utils/common/string_template.py +0 -101
- /kash/{concepts → embeddings}/cosine.py +0 -0
- /kash/{concepts → embeddings}/embeddings.py +0 -0
- {kash_shell-0.3.9.dist-info → kash_shell-0.3.10.dist-info}/WHEEL +0 -0
- {kash_shell-0.3.9.dist-info → kash_shell-0.3.10.dist-info}/entry_points.txt +0 -0
- {kash_shell-0.3.9.dist-info → kash_shell-0.3.10.dist-info}/licenses/LICENSE +0 -0
kash/mcp/mcp_server_commands.py
CHANGED
|
@@ -3,8 +3,7 @@ from pathlib import Path
|
|
|
3
3
|
|
|
4
4
|
from kash.config.logger import get_logger
|
|
5
5
|
from kash.config.settings import (
|
|
6
|
-
|
|
7
|
-
local_server_log_path,
|
|
6
|
+
global_settings,
|
|
8
7
|
)
|
|
9
8
|
from kash.exec import kash_command
|
|
10
9
|
from kash.mcp import mcp_server_routes
|
|
@@ -58,13 +57,14 @@ def mcp_logs(follow: bool = False, all: bool = False) -> None:
|
|
|
58
57
|
:param follow: Follow the file as it grows.
|
|
59
58
|
:param all: Show all logs, not just the server logs, including Claude Desktop logs if found.
|
|
60
59
|
"""
|
|
60
|
+
settings = global_settings()
|
|
61
61
|
if all:
|
|
62
|
-
global_log_base =
|
|
62
|
+
global_log_base = settings.system_logs_dir
|
|
63
63
|
claude_log_base = Path("~/Library/Logs/Claude").expanduser()
|
|
64
64
|
log_paths = []
|
|
65
65
|
did_log = False
|
|
66
66
|
while len(log_paths) == 0:
|
|
67
|
-
log_paths = [local_server_log_path
|
|
67
|
+
log_paths = [settings.local_server_log_path, MCP_CLI_LOG_PATH]
|
|
68
68
|
claude_logs = list(claude_log_base.glob("mcp*.log"))
|
|
69
69
|
if claude_logs:
|
|
70
70
|
log.message("Found Claude Desktop logs, will also tail them: %s", claude_logs)
|
|
@@ -81,7 +81,7 @@ def mcp_logs(follow: bool = False, all: bool = False) -> None:
|
|
|
81
81
|
did_log = True
|
|
82
82
|
time.sleep(1)
|
|
83
83
|
else:
|
|
84
|
-
server_log_path = local_server_log_path
|
|
84
|
+
server_log_path = settings.local_server_log_path # MCP logs shared with local server logs.
|
|
85
85
|
if not server_log_path.exists():
|
|
86
86
|
raise InvalidState(
|
|
87
87
|
f"MCP server log not found (forgot to run `start_mcp_server`?): {server_log_path}"
|
kash/mcp/mcp_server_routes.py
CHANGED
|
@@ -8,16 +8,16 @@ from funlog import log_calls
|
|
|
8
8
|
from mcp.server.lowlevel import Server
|
|
9
9
|
from mcp.types import Prompt, Resource, TextContent, Tool
|
|
10
10
|
from prettyfmt import fmt_path
|
|
11
|
+
from strif import AtomicVar
|
|
11
12
|
|
|
12
13
|
from kash.config.capture_output import CapturedOutput, captured_output
|
|
13
14
|
from kash.config.logger import get_logger
|
|
14
|
-
from kash.config.settings import
|
|
15
|
+
from kash.config.settings import global_settings
|
|
15
16
|
from kash.exec.action_exec import prepare_action_input, run_action_with_caching
|
|
16
17
|
from kash.exec.action_registry import get_all_actions_defaults, look_up_action_class
|
|
17
18
|
from kash.model.actions_model import Action, ActionResult, ExecContext
|
|
18
19
|
from kash.model.params_model import TypedParamValues
|
|
19
20
|
from kash.model.paths_model import StorePath
|
|
20
|
-
from kash.utils.common.atomic_var import AtomicVar
|
|
21
21
|
from kash.workspaces.workspaces import current_ws, get_ws
|
|
22
22
|
|
|
23
23
|
log = get_logger(__name__)
|
|
@@ -213,7 +213,7 @@ def run_mcp_tool(action_name: str, arguments: dict) -> list[TextContent]:
|
|
|
213
213
|
# XXX For now, unless the user has overridden the MCP workspace, we use the
|
|
214
214
|
# current workspace, which could be changed by the user by changing working
|
|
215
215
|
# directories. Maybe confusing?
|
|
216
|
-
explicit_mcp_ws =
|
|
216
|
+
explicit_mcp_ws = global_settings().mcp_ws_dir
|
|
217
217
|
ws = get_ws(explicit_mcp_ws) if explicit_mcp_ws else current_ws()
|
|
218
218
|
|
|
219
219
|
with ws:
|
|
@@ -231,8 +231,8 @@ def run_mcp_tool(action_name: str, arguments: dict) -> list[TextContent]:
|
|
|
231
231
|
context = ExecContext(
|
|
232
232
|
action=action,
|
|
233
233
|
workspace_dir=ws.base_dir,
|
|
234
|
-
# Enabling rerun always for now, seems good for tools.
|
|
235
|
-
|
|
234
|
+
rerun=True, # Enabling rerun always for now, seems good for tools.
|
|
235
|
+
refetch=False, # Using the file caches.
|
|
236
236
|
# Keeping all transient files for now, but maybe make transient?
|
|
237
237
|
override_state=None,
|
|
238
238
|
)
|
kash/mcp/mcp_server_sse.py
CHANGED
|
@@ -18,7 +18,7 @@ if TYPE_CHECKING:
|
|
|
18
18
|
|
|
19
19
|
from kash.config.logger import get_logger
|
|
20
20
|
from kash.config.server_config import create_server_config
|
|
21
|
-
from kash.config.settings import global_settings
|
|
21
|
+
from kash.config.settings import global_settings
|
|
22
22
|
from kash.local_server.port_tools import find_available_local_port
|
|
23
23
|
from kash.mcp import mcp_server_routes
|
|
24
24
|
from kash.utils.errors import InvalidState
|
|
@@ -154,7 +154,9 @@ class MCPServerSSE:
|
|
|
154
154
|
|
|
155
155
|
|
|
156
156
|
# Singleton instance
|
|
157
|
-
_mcp_sse_server = MCPServerSSE(
|
|
157
|
+
_mcp_sse_server = MCPServerSSE(
|
|
158
|
+
MCP_SERVER_NAME, MCP_SERVER_HOST, global_settings().local_server_log_path
|
|
159
|
+
)
|
|
158
160
|
|
|
159
161
|
|
|
160
162
|
def start_mcp_server_sse():
|
kash/media_base/media_cache.py
CHANGED
|
@@ -11,7 +11,7 @@ from kash.media_base.media_services import (
|
|
|
11
11
|
download_media_by_service,
|
|
12
12
|
get_media_services,
|
|
13
13
|
)
|
|
14
|
-
from kash.media_base.
|
|
14
|
+
from kash.media_base.transcription_deepgram import deepgram_transcribe_audio
|
|
15
15
|
from kash.utils.common.format_utils import fmt_loc
|
|
16
16
|
from kash.utils.common.url import Url, as_file_url, is_url
|
|
17
17
|
from kash.utils.errors import FileNotFound, InvalidInput, UnexpectedError
|
|
@@ -88,19 +88,19 @@ class MediaCache(DirStore):
|
|
|
88
88
|
return transcript
|
|
89
89
|
|
|
90
90
|
def cache(
|
|
91
|
-
self, url: Url,
|
|
91
|
+
self, url: Url, refetch=False, media_types: list[MediaType] | None = None
|
|
92
92
|
) -> dict[MediaType, Path]:
|
|
93
93
|
"""
|
|
94
94
|
Cache the media files for the given media URL. Returns paths to cached copies
|
|
95
95
|
for each media type (video or audio). Returns cached copies if available,
|
|
96
|
-
unless `
|
|
96
|
+
unless `refetch` is True.
|
|
97
97
|
"""
|
|
98
98
|
cached_paths: dict[MediaType, Path] = {}
|
|
99
99
|
|
|
100
100
|
if not media_types:
|
|
101
101
|
media_types = [MediaType.audio, MediaType.video]
|
|
102
102
|
|
|
103
|
-
if not
|
|
103
|
+
if not refetch:
|
|
104
104
|
if MediaType.audio in media_types:
|
|
105
105
|
audio_file = self.find(url, suffix=SUFFIX_MP3)
|
|
106
106
|
if audio_file:
|
|
@@ -141,11 +141,11 @@ class MediaCache(DirStore):
|
|
|
141
141
|
return cached_paths
|
|
142
142
|
|
|
143
143
|
def transcribe(
|
|
144
|
-
self, url_or_path: Url | Path,
|
|
144
|
+
self, url_or_path: Url | Path, refetch=False, language: str | None = None
|
|
145
145
|
) -> str:
|
|
146
146
|
"""
|
|
147
147
|
Transcribe the audio file, caching audio, downsampled audio, and the transcription.
|
|
148
|
-
Return the cached transcript if available, unless `
|
|
148
|
+
Return the cached transcript if available, unless `refetch` is True.
|
|
149
149
|
"""
|
|
150
150
|
if not isinstance(url_or_path, Path) and is_url(url_or_path):
|
|
151
151
|
# If it is a URL, cache it locally.
|
|
@@ -156,12 +156,12 @@ class MediaCache(DirStore):
|
|
|
156
156
|
raise InvalidInput(
|
|
157
157
|
"Unrecognized media URL (is this media service configured?): %s" % url_or_path
|
|
158
158
|
)
|
|
159
|
-
if not
|
|
159
|
+
if not refetch:
|
|
160
160
|
transcript = self._read_transcript(url)
|
|
161
161
|
if transcript:
|
|
162
162
|
return transcript
|
|
163
163
|
# Cache all formats since we usually will want them.
|
|
164
|
-
self.cache(url,
|
|
164
|
+
self.cache(url, refetch)
|
|
165
165
|
elif isinstance(url_or_path, Path):
|
|
166
166
|
# Treat local media files as file:// URLs.
|
|
167
167
|
# Don't need to cache originals but we still will cache audio and transcriptions.
|
|
@@ -2,10 +2,10 @@ import logging
|
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
|
|
4
4
|
from funlog import log_calls
|
|
5
|
+
from strif import AtomicVar
|
|
5
6
|
|
|
6
7
|
from kash.media_base.services.local_file_media import LocalFileMedia
|
|
7
8
|
from kash.model.media_model import MediaMetadata, MediaService
|
|
8
|
-
from kash.utils.common.atomic_var import AtomicVar
|
|
9
9
|
from kash.utils.common.url import Url
|
|
10
10
|
from kash.utils.errors import InvalidInput
|
|
11
11
|
from kash.utils.file_utils.file_formats_model import MediaType
|
kash/media_base/media_tools.py
CHANGED
|
@@ -28,20 +28,20 @@ def reset_media_cache_dir(path: Path):
|
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
def cache_and_transcribe(
|
|
31
|
-
url_or_path: Url | Path,
|
|
31
|
+
url_or_path: Url | Path, refetch=False, language: str | None = None
|
|
32
32
|
) -> str:
|
|
33
33
|
"""
|
|
34
|
-
Download and transcribe audio or video, saving in cache. If
|
|
34
|
+
Download and transcribe audio or video, saving in cache. If `refetch` is
|
|
35
35
|
True, force fresh download.
|
|
36
36
|
"""
|
|
37
|
-
return _media_cache.transcribe(url_or_path,
|
|
37
|
+
return _media_cache.transcribe(url_or_path, refetch=refetch, language=language)
|
|
38
38
|
|
|
39
39
|
|
|
40
40
|
def cache_media(
|
|
41
|
-
url: Url,
|
|
41
|
+
url: Url, refetch=False, media_types: list[MediaType] | None = None
|
|
42
42
|
) -> dict[MediaType, Path]:
|
|
43
43
|
"""
|
|
44
|
-
Download audio and video (if available), saving in cache. If
|
|
44
|
+
Download audio and video (if available), saving in cache. If refetch is
|
|
45
45
|
True, force fresh download.
|
|
46
46
|
"""
|
|
47
|
-
return _media_cache.cache(url,
|
|
47
|
+
return _media_cache.cache(url, refetch, media_types)
|
|
@@ -4,13 +4,13 @@ import subprocess # Add this import
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
from urllib.parse import urlparse
|
|
6
6
|
|
|
7
|
+
from clideps.pkgs.pkg_check import pkg_check
|
|
7
8
|
from strif import copyfile_atomic
|
|
8
9
|
from typing_extensions import override
|
|
9
10
|
|
|
10
11
|
from kash.config.logger import get_log_file_stream, get_logger
|
|
11
12
|
from kash.file_storage.store_filenames import parse_item_filename
|
|
12
13
|
from kash.model.media_model import MediaMetadata, MediaService, MediaUrlType
|
|
13
|
-
from kash.shell.clideps.pkg_deps import Pkg, pkg_check
|
|
14
14
|
from kash.utils.common.format_utils import fmt_loc
|
|
15
15
|
from kash.utils.common.url import Url
|
|
16
16
|
from kash.utils.errors import FileNotFound, InvalidInput
|
|
@@ -20,7 +20,7 @@ log = get_logger(__name__)
|
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def _run_ffmpeg(cmdline: list[str]) -> None:
|
|
23
|
-
pkg_check().require(
|
|
23
|
+
pkg_check().require("ffmpeg")
|
|
24
24
|
log.message("Running: %s", " ".join([shlex.quote(arg) for arg in cmdline]))
|
|
25
25
|
subprocess.run(
|
|
26
26
|
cmdline,
|
|
@@ -1,61 +1,23 @@
|
|
|
1
1
|
from os.path import getsize
|
|
2
2
|
from pathlib import Path
|
|
3
|
-
from typing import NamedTuple
|
|
4
3
|
|
|
4
|
+
from clideps.env_vars.dotenv_utils import load_dotenv_paths
|
|
5
|
+
from deepgram import ListenRESTClient, PrerecordedResponse
|
|
5
6
|
from httpx import Timeout
|
|
6
|
-
from openai import OpenAI
|
|
7
7
|
|
|
8
8
|
from kash.config.logger import CustomLogger, get_logger
|
|
9
|
-
from kash.config.settings import
|
|
10
|
-
from kash.media_base.
|
|
11
|
-
from kash.
|
|
12
|
-
from kash.utils.errors import ContentError
|
|
9
|
+
from kash.config.settings import global_settings
|
|
10
|
+
from kash.media_base.transcription_format import SpeakerSegment, format_speaker_segments
|
|
11
|
+
from kash.utils.errors import ApiError, ContentError
|
|
13
12
|
|
|
14
13
|
log: CustomLogger = get_logger(__name__)
|
|
15
14
|
|
|
16
15
|
|
|
17
|
-
def
|
|
16
|
+
def deepgram_transcribe_raw(
|
|
17
|
+
audio_file_path: Path, language: str | None = None
|
|
18
|
+
) -> PrerecordedResponse:
|
|
18
19
|
"""
|
|
19
|
-
Transcribe an audio file
|
|
20
|
-
OpenAI's version does not support diarization and must be under 25MB.
|
|
21
|
-
|
|
22
|
-
https://help.openai.com/en/articles/7031512-whisper-api-faq
|
|
23
|
-
"""
|
|
24
|
-
WHISPER_MAX_SIZE = 25 * 1024 * 1024
|
|
25
|
-
|
|
26
|
-
size = getsize(audio_file_path)
|
|
27
|
-
if size > WHISPER_MAX_SIZE:
|
|
28
|
-
raise ValueError("Audio file too large for Whisper (%s > %s)" % (size, WHISPER_MAX_SIZE))
|
|
29
|
-
log.info(
|
|
30
|
-
"Transcribing via Whisper: %s (size %s)",
|
|
31
|
-
audio_file_path,
|
|
32
|
-
size,
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
client = OpenAI()
|
|
36
|
-
with open(audio_file_path, "rb") as audio_file:
|
|
37
|
-
transcription = client.audio.transcriptions.create(
|
|
38
|
-
model="whisper-1",
|
|
39
|
-
file=audio_file,
|
|
40
|
-
# For when we want timestamps:
|
|
41
|
-
# response_format="verbose_json",
|
|
42
|
-
# timestamp_granularities=["word"]
|
|
43
|
-
)
|
|
44
|
-
text = transcription.text
|
|
45
|
-
return text
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
class SpeakerSegment(NamedTuple):
|
|
49
|
-
words: list[tuple[float, str]]
|
|
50
|
-
start: float
|
|
51
|
-
end: float
|
|
52
|
-
speaker: int
|
|
53
|
-
average_confidence: float
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def deepgram_transcribe_audio(audio_file_path: Path, language: str | None = None) -> str:
|
|
57
|
-
"""
|
|
58
|
-
Transcribe an audio file using Deepgram.
|
|
20
|
+
Transcribe an audio file using Deepgram and return the raw response.
|
|
59
21
|
"""
|
|
60
22
|
from deepgram import ClientOptionsFromEnv, DeepgramClient, FileSource, PrerecordedOptions
|
|
61
23
|
|
|
@@ -64,7 +26,7 @@ def deepgram_transcribe_audio(audio_file_path: Path, language: str | None = None
|
|
|
64
26
|
"Transcribing via Deepgram (language %r): %s (size %s)", language, audio_file_path, size
|
|
65
27
|
)
|
|
66
28
|
|
|
67
|
-
load_dotenv_paths(True, True,
|
|
29
|
+
load_dotenv_paths(True, True, global_settings().system_config_dir)
|
|
68
30
|
deepgram = DeepgramClient("", ClientOptionsFromEnv())
|
|
69
31
|
|
|
70
32
|
with open(audio_file_path, "rb") as audio_file:
|
|
@@ -75,7 +37,17 @@ def deepgram_transcribe_audio(audio_file_path: Path, language: str | None = None
|
|
|
75
37
|
}
|
|
76
38
|
|
|
77
39
|
options = PrerecordedOptions(model="nova-2", smart_format=True, diarize=True, language=language)
|
|
78
|
-
|
|
40
|
+
client: ListenRESTClient = deepgram.listen.rest.v("1") # pyright: ignore
|
|
41
|
+
|
|
42
|
+
response = client.transcribe_file(payload, options, timeout=Timeout(500))
|
|
43
|
+
if not isinstance(response, PrerecordedResponse):
|
|
44
|
+
raise ApiError("Deepgram returned an unexpected response type")
|
|
45
|
+
|
|
46
|
+
return response
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def deepgram_transcribe_audio(audio_file_path: Path, language: str | None = None) -> str:
|
|
50
|
+
response = deepgram_transcribe_raw(audio_file_path, language)
|
|
79
51
|
|
|
80
52
|
log.save_object("Deepgram response", None, response)
|
|
81
53
|
|
|
@@ -87,13 +59,15 @@ def deepgram_transcribe_audio(audio_file_path: Path, language: str | None = None
|
|
|
87
59
|
f"No speaker segments found in Deepgram response (are voices silent or missing?): {audio_file_path}"
|
|
88
60
|
)
|
|
89
61
|
|
|
90
|
-
formatted_segments = format_speaker_segments(diarized_segments)
|
|
62
|
+
formatted_segments = format_speaker_segments(diarized_segments) # noqa: F821
|
|
91
63
|
|
|
92
64
|
return formatted_segments
|
|
93
65
|
|
|
94
66
|
|
|
95
67
|
def _deepgram_diarized_segments(data, confidence_threshold=0.3) -> list[SpeakerSegment]:
|
|
96
|
-
"""
|
|
68
|
+
"""
|
|
69
|
+
Process Deepgram diarized results into text segments per speaker.
|
|
70
|
+
"""
|
|
97
71
|
|
|
98
72
|
speaker_segments: list[SpeakerSegment] = []
|
|
99
73
|
current_speaker = 0
|
|
@@ -164,62 +138,3 @@ def _deepgram_diarized_segments(data, confidence_threshold=0.3) -> list[SpeakerS
|
|
|
164
138
|
)
|
|
165
139
|
|
|
166
140
|
return speaker_segments
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def _is_new_sentence(word: str, next_word: str | None) -> bool:
|
|
170
|
-
return (
|
|
171
|
-
(word.endswith(".") or word.endswith("?") or word.endswith("!"))
|
|
172
|
-
and next_word is not None
|
|
173
|
-
and next_word[0].isupper()
|
|
174
|
-
)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
def _format_words(words: list[tuple[float, str]], include_sentence_timestamps=True) -> str:
|
|
178
|
-
"""Format words with timestamps added in spans."""
|
|
179
|
-
|
|
180
|
-
if not words:
|
|
181
|
-
return ""
|
|
182
|
-
|
|
183
|
-
sentences = []
|
|
184
|
-
current_sentence = []
|
|
185
|
-
for i, (timestamp, word) in enumerate(words):
|
|
186
|
-
current_sentence.append(word)
|
|
187
|
-
next_word = words[i + 1][1] if i + 1 < len(words) else None
|
|
188
|
-
if _is_new_sentence(word, next_word):
|
|
189
|
-
sentences.append((timestamp, current_sentence))
|
|
190
|
-
current_sentence = []
|
|
191
|
-
|
|
192
|
-
if current_sentence:
|
|
193
|
-
sentences.append((words[-1][0], current_sentence))
|
|
194
|
-
|
|
195
|
-
formatted_text = []
|
|
196
|
-
for timestamp, sentence in sentences:
|
|
197
|
-
formatted_sentence = " ".join(sentence)
|
|
198
|
-
if include_sentence_timestamps:
|
|
199
|
-
formatted_text.append(html_timestamp_span(formatted_sentence, timestamp))
|
|
200
|
-
else:
|
|
201
|
-
formatted_text.append(formatted_sentence)
|
|
202
|
-
|
|
203
|
-
return "\n".join(formatted_text)
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
def format_speaker_segments(speaker_segments: list[SpeakerSegment]) -> str:
|
|
207
|
-
"""
|
|
208
|
-
Format speaker segments in a simple HTML format with <span> tags including speaker
|
|
209
|
-
ids and timestamps.
|
|
210
|
-
"""
|
|
211
|
-
|
|
212
|
-
# Use \n\n for readability between segments so each speaker is its own
|
|
213
|
-
# paragraph.
|
|
214
|
-
SEGMENT_SEP = "\n\n"
|
|
215
|
-
|
|
216
|
-
speakers = set(segment.speaker for segment in speaker_segments)
|
|
217
|
-
if len(speakers) > 1:
|
|
218
|
-
lines = []
|
|
219
|
-
for segment in speaker_segments:
|
|
220
|
-
lines.append(
|
|
221
|
-
f"{html_speaker_id_span(f'SPEAKER {segment.speaker}:', str(segment.speaker))}\n{_format_words(segment.words)}"
|
|
222
|
-
)
|
|
223
|
-
return SEGMENT_SEP.join(lines)
|
|
224
|
-
else:
|
|
225
|
-
return SEGMENT_SEP.join(_format_words(segment.words) for segment in speaker_segments)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from typing import NamedTuple
|
|
2
|
+
|
|
3
|
+
from kash.config.logger import CustomLogger, get_logger
|
|
4
|
+
from kash.media_base.timestamp_citations import html_speaker_id_span, html_timestamp_span
|
|
5
|
+
|
|
6
|
+
log: CustomLogger = get_logger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _is_new_sentence(word: str, next_word: str | None) -> bool:
|
|
10
|
+
return (
|
|
11
|
+
(word.endswith(".") or word.endswith("?") or word.endswith("!"))
|
|
12
|
+
and next_word is not None
|
|
13
|
+
and next_word[0].isupper()
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _format_words(words: list[tuple[float, str]], include_sentence_timestamps=True) -> str:
|
|
18
|
+
"""Format words with timestamps added in spans."""
|
|
19
|
+
|
|
20
|
+
if not words:
|
|
21
|
+
return ""
|
|
22
|
+
|
|
23
|
+
sentences = []
|
|
24
|
+
current_sentence = []
|
|
25
|
+
for i, (timestamp, word) in enumerate(words):
|
|
26
|
+
current_sentence.append(word)
|
|
27
|
+
next_word = words[i + 1][1] if i + 1 < len(words) else None
|
|
28
|
+
if _is_new_sentence(word, next_word):
|
|
29
|
+
sentences.append((timestamp, current_sentence))
|
|
30
|
+
current_sentence = []
|
|
31
|
+
|
|
32
|
+
if current_sentence:
|
|
33
|
+
sentences.append((words[-1][0], current_sentence))
|
|
34
|
+
|
|
35
|
+
formatted_text = []
|
|
36
|
+
for timestamp, sentence in sentences:
|
|
37
|
+
formatted_sentence = " ".join(sentence)
|
|
38
|
+
if include_sentence_timestamps:
|
|
39
|
+
formatted_text.append(html_timestamp_span(formatted_sentence, timestamp))
|
|
40
|
+
else:
|
|
41
|
+
formatted_text.append(formatted_sentence)
|
|
42
|
+
|
|
43
|
+
return "\n".join(formatted_text)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SpeakerSegment(NamedTuple):
|
|
47
|
+
words: list[tuple[float, str]]
|
|
48
|
+
start: float
|
|
49
|
+
end: float
|
|
50
|
+
speaker: int
|
|
51
|
+
average_confidence: float
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def format_speaker_segments(speaker_segments: list[SpeakerSegment]) -> str:
|
|
55
|
+
"""
|
|
56
|
+
Format speaker segments in a simple HTML format with <span> tags including speaker
|
|
57
|
+
ids and timestamps.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
# Use \n\n for readability between segments so each speaker is its own
|
|
61
|
+
# paragraph.
|
|
62
|
+
SEGMENT_SEP = "\n\n"
|
|
63
|
+
|
|
64
|
+
speakers = set(segment.speaker for segment in speaker_segments)
|
|
65
|
+
if len(speakers) > 1:
|
|
66
|
+
lines = []
|
|
67
|
+
for segment in speaker_segments:
|
|
68
|
+
lines.append(
|
|
69
|
+
f"{html_speaker_id_span(f'SPEAKER {segment.speaker}:', str(segment.speaker))}\n{_format_words(segment.words)}"
|
|
70
|
+
)
|
|
71
|
+
return SEGMENT_SEP.join(lines)
|
|
72
|
+
else:
|
|
73
|
+
return SEGMENT_SEP.join(_format_words(segment.words) for segment in speaker_segments)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from os.path import getsize
|
|
2
|
+
|
|
3
|
+
from openai import OpenAI
|
|
4
|
+
|
|
5
|
+
from kash.config.logger import CustomLogger, get_logger
|
|
6
|
+
|
|
7
|
+
log: CustomLogger = get_logger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def openai_whisper_transcribe_audio_small(audio_file_path: str) -> str:
|
|
11
|
+
"""
|
|
12
|
+
Transcribe an audio file. Whisper is very good quality but (as of 2024-05)
|
|
13
|
+
OpenAI's version does not support diarization and must be under 25MB.
|
|
14
|
+
|
|
15
|
+
https://help.openai.com/en/articles/7031512-whisper-api-faq
|
|
16
|
+
"""
|
|
17
|
+
WHISPER_MAX_SIZE = 25 * 1024 * 1024
|
|
18
|
+
|
|
19
|
+
size = getsize(audio_file_path)
|
|
20
|
+
if size > WHISPER_MAX_SIZE:
|
|
21
|
+
raise ValueError("Audio file too large for Whisper (%s > %s)" % (size, WHISPER_MAX_SIZE))
|
|
22
|
+
log.info(
|
|
23
|
+
"Transcribing via Whisper: %s (size %s)",
|
|
24
|
+
audio_file_path,
|
|
25
|
+
size,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
client = OpenAI()
|
|
29
|
+
with open(audio_file_path, "rb") as audio_file:
|
|
30
|
+
transcription = client.audio.transcriptions.create(
|
|
31
|
+
model="whisper-1",
|
|
32
|
+
file=audio_file,
|
|
33
|
+
# For when we want timestamps:
|
|
34
|
+
# response_format="verbose_json",
|
|
35
|
+
# timestamp_granularities=["word"]
|
|
36
|
+
)
|
|
37
|
+
text = transcription.text
|
|
38
|
+
return text
|
kash/model/__init__.py
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
The core classes for modeling kash's framework.
|
|
3
|
-
|
|
4
|
-
We include essential logic here but try to keep logic and dependencies minimal.
|
|
5
3
|
"""
|
|
6
4
|
|
|
7
|
-
# flake8: noqa: F401
|
|
8
|
-
|
|
9
5
|
from kash.exec_model.args_model import (
|
|
10
6
|
ANY_ARGS,
|
|
11
7
|
NO_ARGS,
|
|
@@ -31,7 +27,12 @@ from kash.model.actions_model import (
|
|
|
31
27
|
PerItemAction,
|
|
32
28
|
TitleTemplate,
|
|
33
29
|
)
|
|
34
|
-
from kash.model.compound_actions_model import
|
|
30
|
+
from kash.model.compound_actions_model import (
|
|
31
|
+
ComboAction,
|
|
32
|
+
SequenceAction,
|
|
33
|
+
look_up_actions,
|
|
34
|
+
)
|
|
35
|
+
from kash.model.concept_model import Concept, canonicalize_concept, normalize_concepts
|
|
35
36
|
from kash.model.graph_model import GraphData, Link, Node
|
|
36
37
|
from kash.model.items_model import (
|
|
37
38
|
SLUG_MAX_LEN,
|
|
@@ -69,3 +70,70 @@ from kash.model.paths_model import StorePath
|
|
|
69
70
|
from kash.model.preconditions_model import Precondition
|
|
70
71
|
from kash.utils.common.format_utils import fmt_loc
|
|
71
72
|
from kash.utils.file_utils.file_formats_model import FileExt, Format, MediaType
|
|
73
|
+
|
|
74
|
+
__all__ = [
|
|
75
|
+
"ANY_ARGS",
|
|
76
|
+
"NO_ARGS",
|
|
77
|
+
"ONE_ARG",
|
|
78
|
+
"ONE_OR_MORE_ARGS",
|
|
79
|
+
"ONE_OR_NO_ARGS",
|
|
80
|
+
"TWO_ARGS",
|
|
81
|
+
"TWO_OR_MORE_ARGS",
|
|
82
|
+
"ArgCount",
|
|
83
|
+
"CommandArg",
|
|
84
|
+
"Command",
|
|
85
|
+
"CommentedCommand",
|
|
86
|
+
"BareComment",
|
|
87
|
+
"Script",
|
|
88
|
+
"ShellResult",
|
|
89
|
+
"Action",
|
|
90
|
+
"ActionInput",
|
|
91
|
+
"ActionResult",
|
|
92
|
+
"ExecContext",
|
|
93
|
+
"LLMOptions",
|
|
94
|
+
"PathOp",
|
|
95
|
+
"PathOpType",
|
|
96
|
+
"PerItemAction",
|
|
97
|
+
"TitleTemplate",
|
|
98
|
+
"ComboAction",
|
|
99
|
+
"SequenceAction",
|
|
100
|
+
"look_up_actions",
|
|
101
|
+
"Concept",
|
|
102
|
+
"canonicalize_concept",
|
|
103
|
+
"normalize_concepts",
|
|
104
|
+
"GraphData",
|
|
105
|
+
"Link",
|
|
106
|
+
"Node",
|
|
107
|
+
"SLUG_MAX_LEN",
|
|
108
|
+
"UNTITLED",
|
|
109
|
+
"IdType",
|
|
110
|
+
"Item",
|
|
111
|
+
"ItemId",
|
|
112
|
+
"ItemRelations",
|
|
113
|
+
"ItemType",
|
|
114
|
+
"State",
|
|
115
|
+
"SERVICE_APPLE_PODCASTS",
|
|
116
|
+
"SERVICE_VIMEO",
|
|
117
|
+
"SERVICE_YOUTUBE",
|
|
118
|
+
"HeatmapValue",
|
|
119
|
+
"MediaMetadata",
|
|
120
|
+
"MediaService",
|
|
121
|
+
"MediaUrlType",
|
|
122
|
+
"ALL_COMMON_PARAMS",
|
|
123
|
+
"COMMON_ACTION_PARAMS",
|
|
124
|
+
"GLOBAL_PARAMS",
|
|
125
|
+
"RUNTIME_ACTION_PARAMS",
|
|
126
|
+
"USER_SETTABLE_PARAMS",
|
|
127
|
+
"Param",
|
|
128
|
+
"ParamDeclarations",
|
|
129
|
+
"RawParamValues",
|
|
130
|
+
"TypedParamValues",
|
|
131
|
+
"common_param",
|
|
132
|
+
"common_params",
|
|
133
|
+
"StorePath",
|
|
134
|
+
"Precondition",
|
|
135
|
+
"fmt_loc",
|
|
136
|
+
"FileExt",
|
|
137
|
+
"Format",
|
|
138
|
+
"MediaType",
|
|
139
|
+
]
|