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.
Files changed (135) hide show
  1. kash/actions/__init__.py +4 -4
  2. kash/actions/core/markdownify.py +5 -2
  3. kash/actions/core/readability.py +5 -2
  4. kash/actions/core/render_as_html.py +18 -0
  5. kash/actions/core/webpage_config.py +12 -4
  6. kash/commands/__init__.py +8 -20
  7. kash/commands/base/basic_file_commands.py +15 -0
  8. kash/commands/base/debug_commands.py +13 -0
  9. kash/commands/base/general_commands.py +21 -16
  10. kash/commands/base/logs_commands.py +4 -2
  11. kash/commands/base/model_commands.py +8 -8
  12. kash/commands/base/search_command.py +3 -2
  13. kash/commands/base/show_command.py +5 -3
  14. kash/commands/extras/parse_uv_lock.py +186 -0
  15. kash/commands/help/doc_commands.py +2 -31
  16. kash/commands/help/welcome.py +33 -0
  17. kash/commands/workspace/selection_commands.py +11 -6
  18. kash/commands/workspace/workspace_commands.py +18 -15
  19. kash/config/colors.py +2 -0
  20. kash/config/env_settings.py +14 -1
  21. kash/config/init.py +2 -2
  22. kash/config/logger.py +59 -56
  23. kash/config/logger_basic.py +3 -3
  24. kash/config/settings.py +116 -57
  25. kash/config/setup.py +28 -12
  26. kash/config/text_styles.py +3 -13
  27. kash/docs/load_api_docs.py +2 -1
  28. kash/docs/markdown/topics/a3_getting_started.md +3 -2
  29. kash/{concepts → embeddings}/text_similarity.py +2 -2
  30. kash/exec/__init__.py +20 -3
  31. kash/exec/action_decorators.py +18 -4
  32. kash/exec/action_exec.py +41 -23
  33. kash/exec/action_registry.py +13 -48
  34. kash/exec/command_registry.py +2 -1
  35. kash/exec/fetch_url_metadata.py +4 -6
  36. kash/exec/importing.py +56 -0
  37. kash/exec/llm_transforms.py +6 -7
  38. kash/exec/precondition_registry.py +2 -1
  39. kash/exec/preconditions.py +16 -1
  40. kash/exec/shell_callable_action.py +33 -19
  41. kash/file_storage/file_store.py +23 -10
  42. kash/file_storage/item_file_format.py +5 -2
  43. kash/file_storage/metadata_dirs.py +11 -2
  44. kash/help/assistant.py +1 -1
  45. kash/help/assistant_instructions.py +2 -1
  46. kash/help/help_embeddings.py +2 -2
  47. kash/help/help_printing.py +7 -11
  48. kash/llm_utils/clean_headings.py +1 -1
  49. kash/llm_utils/llm_api_keys.py +4 -4
  50. kash/llm_utils/llm_features.py +68 -0
  51. kash/llm_utils/llm_messages.py +1 -2
  52. kash/llm_utils/llm_names.py +1 -1
  53. kash/llm_utils/llms.py +8 -3
  54. kash/local_server/__init__.py +5 -2
  55. kash/local_server/local_server.py +8 -5
  56. kash/local_server/local_server_commands.py +2 -2
  57. kash/local_server/local_url_formatters.py +1 -1
  58. kash/mcp/__init__.py +5 -2
  59. kash/mcp/mcp_cli.py +5 -5
  60. kash/mcp/mcp_server_commands.py +5 -5
  61. kash/mcp/mcp_server_routes.py +5 -5
  62. kash/mcp/mcp_server_sse.py +4 -2
  63. kash/media_base/media_cache.py +8 -8
  64. kash/media_base/media_services.py +1 -1
  65. kash/media_base/media_tools.py +6 -6
  66. kash/media_base/services/local_file_media.py +2 -2
  67. kash/media_base/{speech_transcription.py → transcription_deepgram.py} +25 -110
  68. kash/media_base/transcription_format.py +73 -0
  69. kash/media_base/transcription_whisper.py +38 -0
  70. kash/model/__init__.py +73 -5
  71. kash/model/actions_model.py +38 -4
  72. kash/model/concept_model.py +30 -0
  73. kash/model/items_model.py +44 -7
  74. kash/model/params_model.py +24 -0
  75. kash/shell/completions/completion_scoring.py +37 -5
  76. kash/shell/output/kerm_codes.py +1 -2
  77. kash/shell/output/shell_formatting.py +14 -4
  78. kash/shell/shell_main.py +2 -2
  79. kash/shell/utils/exception_printing.py +6 -0
  80. kash/shell/utils/native_utils.py +26 -20
  81. kash/text_handling/custom_sliding_transforms.py +12 -4
  82. kash/text_handling/doc_normalization.py +6 -2
  83. kash/text_handling/markdown_render.py +117 -0
  84. kash/text_handling/markdown_utils.py +204 -0
  85. kash/utils/common/import_utils.py +12 -3
  86. kash/utils/common/type_utils.py +0 -29
  87. kash/utils/common/url.py +27 -3
  88. kash/utils/errors.py +6 -0
  89. kash/utils/file_utils/file_formats.py +2 -2
  90. kash/utils/file_utils/file_formats_model.py +3 -0
  91. kash/web_content/dir_store.py +1 -2
  92. kash/web_content/file_cache_utils.py +37 -10
  93. kash/web_content/file_processing.py +68 -0
  94. kash/web_content/local_file_cache.py +12 -9
  95. kash/web_content/web_extract.py +8 -3
  96. kash/web_content/web_fetch.py +12 -4
  97. kash/web_gen/tabbed_webpage.py +5 -2
  98. kash/web_gen/templates/base_styles.css.jinja +120 -14
  99. kash/web_gen/templates/base_webpage.html.jinja +60 -13
  100. kash/web_gen/templates/content_styles.css.jinja +4 -2
  101. kash/web_gen/templates/item_view.html.jinja +2 -2
  102. kash/web_gen/templates/tabbed_webpage.html.jinja +1 -2
  103. kash/workspaces/__init__.py +15 -2
  104. kash/workspaces/selections.py +18 -3
  105. kash/workspaces/source_items.py +0 -1
  106. kash/workspaces/workspaces.py +5 -11
  107. kash/xonsh_custom/command_nl_utils.py +40 -19
  108. kash/xonsh_custom/custom_shell.py +43 -11
  109. kash/xonsh_custom/customize_prompt.py +39 -21
  110. kash/xonsh_custom/load_into_xonsh.py +22 -25
  111. kash/xonsh_custom/shell_load_commands.py +2 -2
  112. kash/xonsh_custom/xonsh_completers.py +2 -249
  113. kash/xonsh_custom/xonsh_keybindings.py +282 -0
  114. kash/xonsh_custom/xonsh_modern_tools.py +3 -3
  115. kash/xontrib/kash_extension.py +5 -6
  116. {kash_shell-0.3.9.dist-info → kash_shell-0.3.10.dist-info}/METADATA +8 -6
  117. {kash_shell-0.3.9.dist-info → kash_shell-0.3.10.dist-info}/RECORD +122 -123
  118. kash/concepts/concept_formats.py +0 -23
  119. kash/shell/clideps/api_keys.py +0 -100
  120. kash/shell/clideps/dotenv_setup.py +0 -115
  121. kash/shell/clideps/dotenv_utils.py +0 -98
  122. kash/shell/clideps/pkg_deps.py +0 -257
  123. kash/shell/clideps/platforms.py +0 -11
  124. kash/shell/clideps/terminal_features.py +0 -56
  125. kash/shell/utils/osc_utils.py +0 -95
  126. kash/shell/utils/terminal_images.py +0 -133
  127. kash/text_handling/markdown_util.py +0 -167
  128. kash/utils/common/atomic_var.py +0 -171
  129. kash/utils/common/string_replace.py +0 -93
  130. kash/utils/common/string_template.py +0 -101
  131. /kash/{concepts → embeddings}/cosine.py +0 -0
  132. /kash/{concepts → embeddings}/embeddings.py +0 -0
  133. {kash_shell-0.3.9.dist-info → kash_shell-0.3.10.dist-info}/WHEEL +0 -0
  134. {kash_shell-0.3.9.dist-info → kash_shell-0.3.10.dist-info}/entry_points.txt +0 -0
  135. {kash_shell-0.3.9.dist-info → kash_shell-0.3.10.dist-info}/licenses/LICENSE +0 -0
@@ -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
- get_system_logs_dir,
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 = get_system_logs_dir()
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(), MCP_CLI_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() # MCP logs shared with local server logs.
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}"
@@ -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 get_mcp_ws_dir
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 = get_mcp_ws_dir()
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
- rerun=True,
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
  )
@@ -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, local_server_log_path
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(MCP_SERVER_NAME, MCP_SERVER_HOST, local_server_log_path())
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():
@@ -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.speech_transcription import deepgram_transcribe_audio
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, no_cache=False, media_types: list[MediaType] | None = None
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 `no_cache` is True.
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 no_cache:
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, no_cache=False, language: str | None = None
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 `no_cache` is True.
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 no_cache:
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, no_cache)
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
@@ -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, no_cache=False, language: str | None = None
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 no_cache is
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, no_cache=no_cache, language=language)
37
+ return _media_cache.transcribe(url_or_path, refetch=refetch, language=language)
38
38
 
39
39
 
40
40
  def cache_media(
41
- url: Url, no_cache=False, media_types: list[MediaType] | None = None
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 no_cache is
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, no_cache, media_types)
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(Pkg.ffmpeg)
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 get_system_config_dir
10
- from kash.media_base.timestamp_citations import html_speaker_id_span, html_timestamp_span
11
- from kash.shell.clideps.dotenv_utils import load_dotenv_paths
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 openai_whisper_transcribe_audio_small(audio_file_path: str) -> str:
16
+ def deepgram_transcribe_raw(
17
+ audio_file_path: Path, language: str | None = None
18
+ ) -> PrerecordedResponse:
18
19
  """
19
- Transcribe an audio file. Whisper is very good quality but (as of 2024-05)
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, get_system_config_dir())
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
- response = deepgram.listen.rest.v("1").transcribe_file(payload, options, timeout=Timeout(500)) # pyright: ignore
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
- """Process Deepgram diarized results into text segments per speaker."""
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 ComboAction, SequenceAction, look_up_actions
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
+ ]