secondbrain-py 0.2.1__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.
- brain/__init__.py +0 -0
- brain/__main__.py +18 -0
- brain/_capture_command.py +445 -0
- brain/_compose.py +52 -0
- brain/activity.py +206 -0
- brain/ask.py +631 -0
- brain/audio.py +591 -0
- brain/backfill/__init__.py +12 -0
- brain/backfill/search_extras.py +141 -0
- brain/backfill/source_rows.py +101 -0
- brain/bin/__init__.py +1 -0
- brain/bin/_launcher.py +107 -0
- brain/bin/down.py +8 -0
- brain/bin/launchd.py +268 -0
- brain/bin/monitor.py +570 -0
- brain/bin/rebuild.py +8 -0
- brain/bin/status.py +8 -0
- brain/bin/up.py +8 -0
- brain/brief.py +272 -0
- brain/capture.py +49 -0
- brain/chat.py +293 -0
- brain/cli.py +9760 -0
- brain/cli_claude.py +81 -0
- brain/cli_connect.py +285 -0
- brain/cli_demo.py +266 -0
- brain/config.py +1949 -0
- brain/connect.py +925 -0
- brain/db.py +540 -0
- brain/demo/__init__.py +452 -0
- brain/demo/corpus/manifest.json +403 -0
- brain/demo/embedder.py +74 -0
- brain/durations.py +84 -0
- brain/edit_session.py +156 -0
- brain/editor.py +67 -0
- brain/elicit/__init__.py +16 -0
- brain/elicit/detectors.py +250 -0
- brain/elicit/drafter.py +70 -0
- brain/elicit/queue.py +220 -0
- brain/elicit/schema.py +48 -0
- brain/elicit/session.py +445 -0
- brain/embedding_targets.py +54 -0
- brain/embeddings.py +424 -0
- brain/enrichment.py +808 -0
- brain/errors.py +357 -0
- brain/eval/__init__.py +129 -0
- brain/eval/answer_eval.py +281 -0
- brain/eval/baseline.py +265 -0
- brain/eval/concept_extraction.py +378 -0
- brain/eval/corpus.py +152 -0
- brain/eval/errors.py +19 -0
- brain/eval/graph_baseline.py +226 -0
- brain/eval/graph_retrieval.py +202 -0
- brain/eval/graph_runner.py +319 -0
- brain/eval/metrics.py +101 -0
- brain/eval/runner.py +223 -0
- brain/format.py +783 -0
- brain/gaps.py +390 -0
- brain/graph_rag/__init__.py +94 -0
- brain/graph_rag/_retrieval_common.py +113 -0
- brain/graph_rag/aggregates.py +303 -0
- brain/graph_rag/aliases/__init__.py +583 -0
- brain/graph_rag/backends/__init__.py +10 -0
- brain/graph_rag/backends/_age_helpers.py +473 -0
- brain/graph_rag/backends/age.py +782 -0
- brain/graph_rag/backends/base.py +272 -0
- brain/graph_rag/build.py +344 -0
- brain/graph_rag/communities.py +644 -0
- brain/graph_rag/communities_summary.py +437 -0
- brain/graph_rag/concepts.py +202 -0
- brain/graph_rag/cooccur.py +193 -0
- brain/graph_rag/cross_type.py +312 -0
- brain/graph_rag/extract.py +885 -0
- brain/graph_rag/fuse.py +371 -0
- brain/graph_rag/global_.py +412 -0
- brain/graph_rag/grouping.py +372 -0
- brain/graph_rag/person_resolver.py +167 -0
- brain/graph_rag/reconcile.py +792 -0
- brain/graph_rag/relational.py +353 -0
- brain/graph_rag/retrieve.py +526 -0
- brain/graph_rag/router.py +288 -0
- brain/graph_rag/schema.py +320 -0
- brain/graph_rag/sync.py +237 -0
- brain/graph_rag/tenancy.py +43 -0
- brain/graph_rag/themes.py +501 -0
- brain/graph_rag/weighting.py +202 -0
- brain/ingest/__init__.py +1926 -0
- brain/ingest/chunker.py +249 -0
- brain/ingest/docx.py +40 -0
- brain/ingest/gmail.py +621 -0
- brain/ingest/markdown.py +37 -0
- brain/ingest/pdf.py +61 -0
- brain/ingest/stdin.py +22 -0
- brain/ingest/sub_tokens.py +91 -0
- brain/ingest/text.py +16 -0
- brain/interactions.py +205 -0
- brain/maintenance.py +355 -0
- brain/mcp_server.py +3405 -0
- brain/migrations/001_init.sql +43 -0
- brain/migrations/002_qwen3_embedding.sql +17 -0
- brain/migrations/003_vault_model.sql +41 -0
- brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
- brain/migrations/005_derived_links.sql +67 -0
- brain/migrations/006_dedup_file_by_source_path.sql +25 -0
- brain/migrations/007_email_thread_and_draft.sql +15 -0
- brain/migrations/008_gmail_thread_unique.sql +11 -0
- brain/migrations/009_chunks_weighted_tsv.sql +28 -0
- brain/migrations/010_interactions.sql +30 -0
- brain/migrations/011_documents_summary.sql +23 -0
- brain/migrations/012_graphrag.sql +171 -0
- brain/migrations/013_graphrag_communities.sql +125 -0
- brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
- brain/migrations/015_interactions_graph_targets.sql +89 -0
- brain/migrations/016_index_hygiene.sql +61 -0
- brain/migrations/017_elicit.sql +30 -0
- brain/migrations/018_review_gap_signal_kinds.sql +40 -0
- brain/migrations/019_search_queries.sql +35 -0
- brain/migrations/020_link_suggestions.sql +40 -0
- brain/migrations/021_timeline_doc_date.sql +34 -0
- brain/migrations/022_link_suggestions_undirected.sql +84 -0
- brain/migrations/023_search_queries_fts_count.sql +28 -0
- brain/quartz_overrides/__init__.py +8 -0
- brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
- brain/quartz_overrides/quartz/build.ts +568 -0
- brain/quartz_overrides/quartz/cli/args.js +152 -0
- brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
- brain/quartz_overrides/quartz/cli/handlers.js +636 -0
- brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
- brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
- brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
- brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
- brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
- brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
- brain/quartz_overrides/quartz/components/Search.tsx +161 -0
- brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
- brain/quartz_overrides/quartz/components/index.ts +92 -0
- brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
- brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
- brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
- brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
- brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
- brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
- brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
- brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
- brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
- brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
- brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
- brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
- brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
- brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
- brain/quartz_overrides/quartz/processors/parse.ts +371 -0
- brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
- brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
- brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
- brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
- brain/quartz_overrides/quartz/static/emailThread.js +334 -0
- brain/quartz_overrides/quartz/static/favicon.ico +0 -0
- brain/quartz_overrides/quartz/static/icon.png +0 -0
- brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
- brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
- brain/quartz_overrides/quartz/static/reload.js +168 -0
- brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
- brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
- brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
- brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
- brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
- brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
- brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
- brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
- brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
- brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
- brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
- brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
- brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
- brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
- brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
- brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
- brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
- brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
- brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
- brain/quartz_overrides/quartz/styles/custom.scss +89 -0
- brain/quartz_overrides/quartz/styles/graph.scss +505 -0
- brain/quartz_overrides/quartz/util/ctx.ts +92 -0
- brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
- brain/quartz_overrides/quartz/util/path.ts +358 -0
- brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
- brain/quartz_overrides/quartz.config.ts +270 -0
- brain/quartz_overrides/quartz.layout.ts +314 -0
- brain/queries.py +1188 -0
- brain/rank_fusion.py +8 -0
- brain/resurface.py +210 -0
- brain/review/__init__.py +26 -0
- brain/review/emit.py +27 -0
- brain/review/queries.py +436 -0
- brain/review/render.py +196 -0
- brain/review/scans.py +355 -0
- brain/review/weekly.py +413 -0
- brain/search.py +704 -0
- brain/set_similarity.py +15 -0
- brain/setup.py +1205 -0
- brain/tags.py +56 -0
- brain/templates/Caddyfile.j2 +9 -0
- brain/templates/__init__.py +1 -0
- brain/templates/bin/__init__.py +1 -0
- brain/templates/bin/_brain-brief-fg.sh +25 -0
- brain/templates/bin/_brain-build-fg.sh +53 -0
- brain/templates/bin/_brain-watcher-fg.sh +65 -0
- brain/templates/bin/brain-down.sh +89 -0
- brain/templates/bin/brain-status.sh +83 -0
- brain/templates/bin/brain-up.sh +221 -0
- brain/templates/docker/age/Dockerfile +79 -0
- brain/templates/docker-compose.stock.yml.j2 +26 -0
- brain/templates/docker-compose.yml.j2 +34 -0
- brain/templates/env.example +190 -0
- brain/templates/launchd/__init__.py +1 -0
- brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
- brain/templates/launchd/com.brain.build.plist.j2 +46 -0
- brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
- brain/templates/skill/SKILL.md +63 -0
- brain/templates/skill/__init__.py +1 -0
- brain/timeline.py +834 -0
- brain/todo.py +124 -0
- brain/uninstall.py +185 -0
- brain/vault/__init__.py +115 -0
- brain/vault/_atomic.py +25 -0
- brain/vault/daily_index.py +228 -0
- brain/vault/derived_links/__init__.py +50 -0
- brain/vault/derived_links/directory.py +683 -0
- brain/vault/derived_links/fence.py +408 -0
- brain/vault/derived_links/gws.py +64 -0
- brain/vault/derived_links/participants.py +143 -0
- brain/vault/derived_links/pass_runner.py +362 -0
- brain/vault/derived_links/rules.py +137 -0
- brain/vault/export.py +683 -0
- brain/vault/frontmatter.py +165 -0
- brain/vault/graph.py +620 -0
- brain/vault/graph_format.py +388 -0
- brain/vault/link_rewrite.py +235 -0
- brain/vault/links.py +260 -0
- brain/vault/note_builder.py +211 -0
- brain/vault/paths.py +55 -0
- brain/vault/quartz_overlay.py +236 -0
- brain/vault/rename.py +591 -0
- brain/vault/resolver.py +304 -0
- brain/vault/slug.py +127 -0
- brain/vault/sync.py +1513 -0
- brain/vault/sync_summaries.py +264 -0
- brain/vault/templates.py +145 -0
- brain/vault/watch.py +1052 -0
- brain/wiki/__init__.py +6 -0
- brain/wiki/_github_slugger.py +76 -0
- brain/wiki/_person_name.py +314 -0
- brain/wiki/build_homepage.py +541 -0
- brain/wiki/build_partial.py +273 -0
- brain/wiki/build_people.py +934 -0
- brain/wiki/build_related.py +758 -0
- brain/wiki/build_swap.py +585 -0
- brain/wiki/build_watcher.py +975 -0
- brain/wiki/edit_classifier.py +215 -0
- brain/wiki/errors.py +10 -0
- brain/wiki/fastpath_manifest.py +475 -0
- brain/wiki/fastpath_state.py +174 -0
- brain/wiki/install.py +296 -0
- brain/wiki/slug.py +111 -0
- secondbrain_py-0.2.1.dist-info/METADATA +195 -0
- secondbrain_py-0.2.1.dist-info/RECORD +273 -0
- secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
- secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
- secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
- secondbrain_py-0.2.1.dist-info/top_level.txt +1 -0
brain/audio.py
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
"""Plan 04 — `brain audio` two-host audio-overview script generation.
|
|
2
|
+
|
|
3
|
+
Turns a GraphRAG theme/community bundle into a NotebookLM-style two-host
|
|
4
|
+
podcast script (``PodcastScript``), plus a pluggable ``TtsBackend`` Protocol for
|
|
5
|
+
optional synthesis. This module owns ONE reason to change: bundle → prompt →
|
|
6
|
+
script. Graph retrieval, artifact writing, and CLI orchestration live in
|
|
7
|
+
``cli.py``; the LLM round-trip is delegated to the shared
|
|
8
|
+
:func:`brain.chat.chat_json` helper (consumed AS-IS — no signature drift).
|
|
9
|
+
|
|
10
|
+
Privacy contract: the bundle carries ONLY entity names, theme summaries, and
|
|
11
|
+
per-document ``(title, summary)`` pairs (``documents.summary`` is the ≤60-word
|
|
12
|
+
enrichment projection). Full document bodies never reach the prompt — the bundle
|
|
13
|
+
is assembled from :func:`brain.queries.fetch_document_summary`, which selects
|
|
14
|
+
``title``/``summary`` only.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import shlex
|
|
20
|
+
import subprocess
|
|
21
|
+
from collections.abc import Callable, Sequence
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
25
|
+
|
|
26
|
+
from .chat import chat_json
|
|
27
|
+
from .errors import AudioError, EnrichmentError, OllamaUnavailable, TtsError
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
import psycopg
|
|
31
|
+
|
|
32
|
+
from .config import Config
|
|
33
|
+
from .graph_rag.schema import GraphContext, GraphEntity
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _GroupLike(Protocol):
|
|
37
|
+
"""Structural shape shared by ``ThemeGroup`` and ``CommunityGroup``.
|
|
38
|
+
|
|
39
|
+
Both carry the fields the bundle assembly reads (``score``/``summary``/
|
|
40
|
+
``entities``/``doc_ids``); this Protocol lets one code path handle either
|
|
41
|
+
without a runtime ``isinstance`` branch (Liskov substitution). Declared as
|
|
42
|
+
read-only properties so a ``Sequence[_GroupLike]`` stays covariant over the
|
|
43
|
+
two concrete frozen dataclasses.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def score(self) -> float: ...
|
|
48
|
+
@property
|
|
49
|
+
def summary(self) -> str | None: ...
|
|
50
|
+
@property
|
|
51
|
+
def entities(self) -> list[GraphEntity]: ...
|
|
52
|
+
@property
|
|
53
|
+
def doc_ids(self) -> list[str]: ...
|
|
54
|
+
|
|
55
|
+
_logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
# Speaker labels for the two-host format. The generator validates every turn's
|
|
58
|
+
# speaker resolves to exactly one of these (spec §5 test_script_generator_speaker_names).
|
|
59
|
+
HOST = "Host"
|
|
60
|
+
GUEST = "Guest"
|
|
61
|
+
_VALID_SPEAKERS = (HOST, GUEST)
|
|
62
|
+
|
|
63
|
+
# Source-kind discriminators carried on the bundle + the PodcastScript.
|
|
64
|
+
KIND_THEMES = "themes"
|
|
65
|
+
KIND_GLOBAL = "global"
|
|
66
|
+
|
|
67
|
+
# Top-N representative documents pulled per group (spec §3 step 3c "top-2").
|
|
68
|
+
_DOCS_PER_GROUP = 2
|
|
69
|
+
|
|
70
|
+
# Default completion-length budget for the dialogue call (spec §3 step 4c).
|
|
71
|
+
_DEFAULT_NUM_PREDICT = 2048
|
|
72
|
+
|
|
73
|
+
# The schema handed to ``chat_json`` — its KEYS are the required top-level keys
|
|
74
|
+
# of the returned JSON object. The generator does the deeper per-turn structural
|
|
75
|
+
# validation itself (chat_json only checks key presence).
|
|
76
|
+
_TURNS_SCHEMA: dict[str, Any] = {"turns": "list of {speaker, text} dialogue turns"}
|
|
77
|
+
|
|
78
|
+
# A callable matching the shape the generator needs from ``chat_json``:
|
|
79
|
+
# ``(prompt, schema, num_predict) -> parsed JSON object``. Dependency-inverted so
|
|
80
|
+
# tests inject a fake without a live Ollama.
|
|
81
|
+
ChatFn = Callable[[str, dict[str, Any], int], dict[str, Any]]
|
|
82
|
+
|
|
83
|
+
# A token counter ``(text) -> int``. Injected so prompt budgeting is testable
|
|
84
|
+
# without tiktoken; production uses the cl100k_base counter below.
|
|
85
|
+
TokenCounter = Callable[[str], int]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --------------------------------------------------------------------------- #
|
|
89
|
+
# Value objects
|
|
90
|
+
# --------------------------------------------------------------------------- #
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class ScriptTurn:
|
|
93
|
+
"""One speaker turn in the dialogue. ``speaker`` is ``Host`` or ``Guest``."""
|
|
94
|
+
|
|
95
|
+
speaker: str
|
|
96
|
+
text: str
|
|
97
|
+
|
|
98
|
+
def to_dict(self) -> dict[str, str]:
|
|
99
|
+
"""Serialize to the wire shape ``{"speaker", "text"}``."""
|
|
100
|
+
return {"speaker": self.speaker, "text": self.text}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class PodcastScript:
|
|
105
|
+
"""A generated two-host audio-overview script (spec §3 script schema).
|
|
106
|
+
|
|
107
|
+
``generated_at`` is an ISO-8601 UTC string stamped by the CLI caller (this
|
|
108
|
+
module never calls ``datetime.now()`` — workflow-resume safe). ``model`` is
|
|
109
|
+
the Ollama model that produced the dialogue.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
title: str
|
|
113
|
+
source_kind: str
|
|
114
|
+
source_person: str | None
|
|
115
|
+
source_topic: str | None
|
|
116
|
+
theme_count: int
|
|
117
|
+
generated_at: str
|
|
118
|
+
model: str
|
|
119
|
+
turns: list[ScriptTurn]
|
|
120
|
+
|
|
121
|
+
def to_dict(self) -> dict[str, Any]:
|
|
122
|
+
"""Serialize to the public JSON artifact shape (spec §3)."""
|
|
123
|
+
return {
|
|
124
|
+
"title": self.title,
|
|
125
|
+
"source": {
|
|
126
|
+
"kind": self.source_kind,
|
|
127
|
+
"person": self.source_person,
|
|
128
|
+
"topic": self.source_topic,
|
|
129
|
+
"theme_count": self.theme_count,
|
|
130
|
+
},
|
|
131
|
+
"generated_at": self.generated_at,
|
|
132
|
+
"model": self.model,
|
|
133
|
+
"turns": [turn.to_dict() for turn in self.turns],
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
def to_markdown(self) -> str:
|
|
137
|
+
"""Render the human-editable Markdown transcript artifact (spec §3)."""
|
|
138
|
+
lines = [f"## {self.title}", ""]
|
|
139
|
+
for turn in self.turns:
|
|
140
|
+
lines.append(f"> **{turn.speaker}:** {turn.text}")
|
|
141
|
+
lines.append("")
|
|
142
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class BundleGroup:
|
|
147
|
+
"""One theme/community in the bundle: entity labels + summaries (no bodies)."""
|
|
148
|
+
|
|
149
|
+
entity_names: list[str] = field(default_factory=list)
|
|
150
|
+
summary: str | None = None
|
|
151
|
+
# Per-document (title, summary) pairs — never full body content.
|
|
152
|
+
docs: list[tuple[str, str | None]] = field(default_factory=list)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(frozen=True)
|
|
156
|
+
class SourceBundle:
|
|
157
|
+
"""The internal value object fed to the script prompt (not serialized).
|
|
158
|
+
|
|
159
|
+
Assembled from a :class:`~brain.graph_rag.schema.GraphContext` by
|
|
160
|
+
:func:`bundle_from_graph_context`. Carries entity-name lists + per-doc
|
|
161
|
+
``(title, summary)`` pairs only — the privacy boundary.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
kind: str
|
|
165
|
+
person: str | None
|
|
166
|
+
topic: str | None
|
|
167
|
+
groups: list[BundleGroup] = field(default_factory=list)
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def theme_count(self) -> int:
|
|
171
|
+
"""Number of groups in the bundle (the script's ``theme_count``)."""
|
|
172
|
+
return len(self.groups)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# A summary fetcher ``(conn, document_id) -> (title, summary) | None``. Injected
|
|
176
|
+
# so the bundle assembly is testable without the default queries import.
|
|
177
|
+
SummaryFetcher = Callable[
|
|
178
|
+
["psycopg.Connection[Any]", str], "tuple[str, str | None] | None"
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# --------------------------------------------------------------------------- #
|
|
183
|
+
# Bundle assembly
|
|
184
|
+
# --------------------------------------------------------------------------- #
|
|
185
|
+
def _select_top_groups(
|
|
186
|
+
groups: Sequence[_GroupLike], theme_limit: int
|
|
187
|
+
) -> list[_GroupLike]:
|
|
188
|
+
"""Return the top ``theme_limit`` groups by ``score`` DESC (stable tiebreak).
|
|
189
|
+
|
|
190
|
+
The original index is the deterministic tiebreak so equal-score groups keep
|
|
191
|
+
their retrieval order across runs. Typed against :class:`_GroupLike` so one
|
|
192
|
+
body ranks ``ThemeGroup``s and ``CommunityGroup``s alike.
|
|
193
|
+
"""
|
|
194
|
+
scored = [(group.score, index, group) for index, group in enumerate(groups)]
|
|
195
|
+
scored.sort(key=lambda item: (-item[0], item[1]))
|
|
196
|
+
return [group for _, _, group in scored[:theme_limit]]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def bundle_from_graph_context(
|
|
200
|
+
conn: psycopg.Connection[Any],
|
|
201
|
+
ctx: GraphContext,
|
|
202
|
+
*,
|
|
203
|
+
theme_limit: int,
|
|
204
|
+
fetch_summary: SummaryFetcher | None = None,
|
|
205
|
+
) -> SourceBundle:
|
|
206
|
+
"""Assemble a :class:`SourceBundle` from a graph retrieval context.
|
|
207
|
+
|
|
208
|
+
Takes the top ``theme_limit`` groups (``ctx.themes`` for themes mode, else
|
|
209
|
+
``ctx.communities`` for global mode) ranked by ``score`` descending — a
|
|
210
|
+
stable original-index tiebreak keeps repeated runs byte-identical. For each
|
|
211
|
+
group it collects entity names + the group ``summary`` + the top
|
|
212
|
+
:data:`_DOCS_PER_GROUP` documents' ``(title, summary)`` pairs via
|
|
213
|
+
``fetch_summary`` (default :func:`brain.queries.fetch_document_summary`,
|
|
214
|
+
which selects ``title``/``summary`` only — never ``content``).
|
|
215
|
+
|
|
216
|
+
Groups without a summary fall back to entity names + doc titles, so the
|
|
217
|
+
prompt is never empty even on an un-enriched corpus.
|
|
218
|
+
"""
|
|
219
|
+
if fetch_summary is None:
|
|
220
|
+
from .queries import fetch_document_summary
|
|
221
|
+
|
|
222
|
+
fetch_summary = fetch_document_summary
|
|
223
|
+
|
|
224
|
+
if ctx.themes:
|
|
225
|
+
kind = KIND_THEMES
|
|
226
|
+
selected = _select_top_groups(ctx.themes, theme_limit)
|
|
227
|
+
else:
|
|
228
|
+
kind = KIND_GLOBAL
|
|
229
|
+
selected = _select_top_groups(ctx.communities, theme_limit)
|
|
230
|
+
|
|
231
|
+
groups: list[BundleGroup] = []
|
|
232
|
+
for group in selected:
|
|
233
|
+
entity_names = [
|
|
234
|
+
entity.name for entity in group.entities if entity.name.strip()
|
|
235
|
+
]
|
|
236
|
+
docs: list[tuple[str, str | None]] = []
|
|
237
|
+
for doc_id in group.doc_ids[:_DOCS_PER_GROUP]:
|
|
238
|
+
fetched = fetch_summary(conn, doc_id)
|
|
239
|
+
if fetched is not None:
|
|
240
|
+
docs.append(fetched)
|
|
241
|
+
groups.append(
|
|
242
|
+
BundleGroup(entity_names=entity_names, summary=group.summary, docs=docs)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
return SourceBundle(
|
|
246
|
+
kind=kind,
|
|
247
|
+
person=ctx.person,
|
|
248
|
+
topic=ctx.query if kind == KIND_GLOBAL else None,
|
|
249
|
+
groups=groups,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# --------------------------------------------------------------------------- #
|
|
254
|
+
# Title + prompt construction (pure)
|
|
255
|
+
# --------------------------------------------------------------------------- #
|
|
256
|
+
def make_title(bundle: SourceBundle) -> str:
|
|
257
|
+
"""Build a deterministic episode title from the bundle's source."""
|
|
258
|
+
if bundle.kind == KIND_THEMES and bundle.person:
|
|
259
|
+
return f"Themes From My Conversations With {bundle.person}"
|
|
260
|
+
if bundle.kind == KIND_GLOBAL and bundle.topic:
|
|
261
|
+
return f"A Conversation About {bundle.topic}"
|
|
262
|
+
return "An Audio Overview From My Second Brain"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
_SYSTEM_FRAMING = (
|
|
266
|
+
"You are the script writer for a two-host audio overview, in the style of a "
|
|
267
|
+
"short explanatory podcast. Two hosts — Host and Guest — discuss the source "
|
|
268
|
+
"material below in a natural, curious, back-and-forth conversation. Host "
|
|
269
|
+
"leads and asks questions; Guest adds insight and detail. Ground EVERY "
|
|
270
|
+
"statement strictly in the provided material — never invent facts, names, "
|
|
271
|
+
"numbers, or quotes. Keep each turn to two or three sentences."
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _format_group(index: int, group: BundleGroup) -> str:
|
|
276
|
+
"""Render one bundle group as a prompt section (entity names + summaries)."""
|
|
277
|
+
lines = [f"Theme {index}:"]
|
|
278
|
+
if group.entity_names:
|
|
279
|
+
lines.append(f"- Key topics: {', '.join(group.entity_names)}")
|
|
280
|
+
if group.summary:
|
|
281
|
+
lines.append(f"- Summary: {group.summary}")
|
|
282
|
+
for title, summary in group.docs:
|
|
283
|
+
if summary:
|
|
284
|
+
lines.append(f"- Source “{title}”: {summary}")
|
|
285
|
+
else:
|
|
286
|
+
lines.append(f"- Source “{title}”")
|
|
287
|
+
return "\n".join(lines)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def build_prompt(
|
|
291
|
+
bundle: SourceBundle,
|
|
292
|
+
*,
|
|
293
|
+
max_turns: int,
|
|
294
|
+
max_input_tokens: int,
|
|
295
|
+
count_tokens: TokenCounter,
|
|
296
|
+
) -> str:
|
|
297
|
+
"""Build the single user prompt for the dialogue call (privacy-bounded).
|
|
298
|
+
|
|
299
|
+
Greedily includes bundle groups while the running prompt stays within
|
|
300
|
+
``max_input_tokens`` (measured by the injected ``count_tokens``); the first
|
|
301
|
+
group is always included even if it alone exceeds the budget, so a prompt is
|
|
302
|
+
never empty. The model is instructed to emit JSON ``{"turns": [...]}`` with
|
|
303
|
+
at most ``max_turns`` alternating Host/Guest turns.
|
|
304
|
+
"""
|
|
305
|
+
header = _SYSTEM_FRAMING
|
|
306
|
+
instruction = (
|
|
307
|
+
f"Write a dialogue of AT MOST {max_turns} turns, strictly alternating "
|
|
308
|
+
f'"{HOST}" and "{GUEST}", starting with {HOST}. Respond with ONLY a JSON '
|
|
309
|
+
'object of the form {"turns": [{"speaker": "Host", "text": "..."}, '
|
|
310
|
+
'{"speaker": "Guest", "text": "..."}]}. Use no speaker names other than '
|
|
311
|
+
f'"{HOST}" and "{GUEST}".'
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
included: list[str] = []
|
|
315
|
+
for index, group in enumerate(bundle.groups, start=1):
|
|
316
|
+
section = _format_group(index, group)
|
|
317
|
+
candidate = "\n\n".join([header, *included, section, instruction])
|
|
318
|
+
if included and count_tokens(candidate) > max_input_tokens:
|
|
319
|
+
_logger.debug(
|
|
320
|
+
"audio prompt: stopping at %d/%d groups (token budget %d)",
|
|
321
|
+
len(included),
|
|
322
|
+
len(bundle.groups),
|
|
323
|
+
max_input_tokens,
|
|
324
|
+
)
|
|
325
|
+
break
|
|
326
|
+
included.append(section)
|
|
327
|
+
|
|
328
|
+
return "\n\n".join([header, *included, instruction])
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# --------------------------------------------------------------------------- #
|
|
332
|
+
# Turn parsing / validation (pure)
|
|
333
|
+
# --------------------------------------------------------------------------- #
|
|
334
|
+
def _normalize_speaker(raw: object) -> str | None:
|
|
335
|
+
"""Map a raw speaker label to ``Host`` / ``Guest`` (or ``None`` if neither)."""
|
|
336
|
+
if not isinstance(raw, str):
|
|
337
|
+
return None
|
|
338
|
+
token = raw.strip().lower()
|
|
339
|
+
if token.startswith("host"):
|
|
340
|
+
return HOST
|
|
341
|
+
if token.startswith("guest"):
|
|
342
|
+
return GUEST
|
|
343
|
+
return None
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _parse_turns(response: dict[str, Any]) -> list[ScriptTurn]:
|
|
347
|
+
"""Validate the model response into a list of :class:`ScriptTurn`.
|
|
348
|
+
|
|
349
|
+
Raises :class:`AudioError` (which the generator catches to trigger its one
|
|
350
|
+
retry) when ``turns`` is not a list of ``{"speaker", "text"}`` objects with a
|
|
351
|
+
resolvable speaker and non-empty text.
|
|
352
|
+
"""
|
|
353
|
+
raw_turns = response.get("turns")
|
|
354
|
+
if not isinstance(raw_turns, list) or not raw_turns:
|
|
355
|
+
raise AudioError("script response 'turns' is not a non-empty list")
|
|
356
|
+
turns: list[ScriptTurn] = []
|
|
357
|
+
for item in raw_turns:
|
|
358
|
+
if not isinstance(item, dict):
|
|
359
|
+
raise AudioError(f"script turn is not an object: {item!r}")
|
|
360
|
+
speaker = _normalize_speaker(item.get("speaker"))
|
|
361
|
+
text = item.get("text")
|
|
362
|
+
if speaker is None:
|
|
363
|
+
raise AudioError(f"script turn has an invalid speaker: {item!r}")
|
|
364
|
+
if not isinstance(text, str) or not text.strip():
|
|
365
|
+
raise AudioError(f"script turn has empty/invalid text: {item!r}")
|
|
366
|
+
turns.append(ScriptTurn(speaker=speaker, text=text.strip()))
|
|
367
|
+
return turns
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# --------------------------------------------------------------------------- #
|
|
371
|
+
# Script generator
|
|
372
|
+
# --------------------------------------------------------------------------- #
|
|
373
|
+
class ScriptGenerator:
|
|
374
|
+
"""Generates a :class:`PodcastScript` from a :class:`SourceBundle`.
|
|
375
|
+
|
|
376
|
+
The LLM round-trip is injected as ``chat_fn`` (dependency inversion) so tests
|
|
377
|
+
drive it with a fake; production wires :func:`brain.chat.chat_json` bound to
|
|
378
|
+
``cfg.audio_script_model`` via :func:`make_script_generator`. The generator
|
|
379
|
+
owns the prompt build, the structural validation + one retry, and the
|
|
380
|
+
surplus-turn truncation.
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
def __init__(
|
|
384
|
+
self,
|
|
385
|
+
*,
|
|
386
|
+
chat_fn: ChatFn,
|
|
387
|
+
model: str,
|
|
388
|
+
max_turns: int,
|
|
389
|
+
max_input_tokens: int,
|
|
390
|
+
count_tokens: TokenCounter,
|
|
391
|
+
num_predict: int = _DEFAULT_NUM_PREDICT,
|
|
392
|
+
) -> None:
|
|
393
|
+
self._chat_fn = chat_fn
|
|
394
|
+
self._model = model
|
|
395
|
+
self._max_turns = max_turns
|
|
396
|
+
self._max_input_tokens = max_input_tokens
|
|
397
|
+
self._count_tokens = count_tokens
|
|
398
|
+
self._num_predict = num_predict
|
|
399
|
+
|
|
400
|
+
def generate(
|
|
401
|
+
self, bundle: SourceBundle, *, title: str, generated_at: str
|
|
402
|
+
) -> PodcastScript:
|
|
403
|
+
"""Produce a script for ``bundle`` (title + ``generated_at`` injected)."""
|
|
404
|
+
prompt = build_prompt(
|
|
405
|
+
bundle,
|
|
406
|
+
max_turns=self._max_turns,
|
|
407
|
+
max_input_tokens=self._max_input_tokens,
|
|
408
|
+
count_tokens=self._count_tokens,
|
|
409
|
+
)
|
|
410
|
+
turns = self._generate_turns(prompt)
|
|
411
|
+
if len(turns) > self._max_turns:
|
|
412
|
+
_logger.debug(
|
|
413
|
+
"audio: truncating %d turns to max_turns=%d",
|
|
414
|
+
len(turns),
|
|
415
|
+
self._max_turns,
|
|
416
|
+
)
|
|
417
|
+
turns = turns[: self._max_turns]
|
|
418
|
+
return PodcastScript(
|
|
419
|
+
title=title,
|
|
420
|
+
source_kind=bundle.kind,
|
|
421
|
+
source_person=bundle.person,
|
|
422
|
+
source_topic=bundle.topic,
|
|
423
|
+
theme_count=bundle.theme_count,
|
|
424
|
+
generated_at=generated_at,
|
|
425
|
+
model=self._model,
|
|
426
|
+
turns=turns,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
def _generate_turns(self, prompt: str) -> list[ScriptTurn]:
|
|
430
|
+
"""Call ``chat_fn`` and validate; retry once on a structural failure.
|
|
431
|
+
|
|
432
|
+
``chat_fn`` (production: :func:`brain.chat.chat_json`) raises
|
|
433
|
+
:class:`OllamaUnavailable` on a transport failure — propagated so the CLI
|
|
434
|
+
maps it to its distinct "Ollama unavailable" path — and
|
|
435
|
+
:class:`EnrichmentError` when it exhausts its OWN retries on
|
|
436
|
+
malformed-JSON / schema-violating responses; that is unrecoverable for
|
|
437
|
+
audio, so it is converted to :class:`AudioError` (the Plan 04 clean
|
|
438
|
+
error contract — the CLI catches only ``OllamaUnavailable`` /
|
|
439
|
+
``AudioError``). A structurally-invalid-but-parseable response
|
|
440
|
+
(:class:`AudioError` from :func:`_parse_turns`) is retried once.
|
|
441
|
+
"""
|
|
442
|
+
last_error: AudioError | None = None
|
|
443
|
+
for _ in (1, 2):
|
|
444
|
+
try:
|
|
445
|
+
response = self._chat_fn(prompt, _TURNS_SCHEMA, self._num_predict)
|
|
446
|
+
except OllamaUnavailable:
|
|
447
|
+
# Transport failure — let the CLI map it distinctly. (Subclass of
|
|
448
|
+
# EnrichmentError, so this except MUST precede the next one.)
|
|
449
|
+
raise
|
|
450
|
+
except EnrichmentError as exc:
|
|
451
|
+
# chat_json already retried JSON parsing internally; a persistent
|
|
452
|
+
# failure is unrecoverable — surface as the audio error contract.
|
|
453
|
+
raise AudioError(f"script generation failed: {exc}") from exc
|
|
454
|
+
try:
|
|
455
|
+
return _parse_turns(response)
|
|
456
|
+
except AudioError as exc:
|
|
457
|
+
last_error = exc
|
|
458
|
+
continue
|
|
459
|
+
raise AudioError(
|
|
460
|
+
f"script generation failed after 2 attempts: {last_error}"
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _default_count_tokens(text: str) -> int:
|
|
465
|
+
"""Count tokens via tiktoken ``cl100k_base`` (the project-wide tokenizer)."""
|
|
466
|
+
import tiktoken
|
|
467
|
+
|
|
468
|
+
encoder = tiktoken.get_encoding("cl100k_base")
|
|
469
|
+
return len(encoder.encode(text))
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def make_script_generator(
|
|
473
|
+
cfg: Config, *, max_turns: int | None = None
|
|
474
|
+
) -> ScriptGenerator:
|
|
475
|
+
"""Build the production :class:`ScriptGenerator` from ``cfg``.
|
|
476
|
+
|
|
477
|
+
Wires :func:`brain.chat.chat_json` bound to ``cfg.audio_script_model`` (NOT
|
|
478
|
+
``cfg.enrich_model``) as the ``chat_fn``, the tiktoken token counter, and the
|
|
479
|
+
audio caps. The chat helper handles JSON-mode + one transport-level retry;
|
|
480
|
+
``ScriptGenerator`` layers its own structural validation + retry on top.
|
|
481
|
+
|
|
482
|
+
``max_turns`` overrides ``cfg.audio_max_turns`` (the ``--turns`` CLI flag);
|
|
483
|
+
the caller is responsible for validating it is a positive even integer.
|
|
484
|
+
"""
|
|
485
|
+
|
|
486
|
+
def _chat(
|
|
487
|
+
prompt: str, schema: dict[str, Any], num_predict: int
|
|
488
|
+
) -> dict[str, Any]:
|
|
489
|
+
return chat_json(
|
|
490
|
+
prompt,
|
|
491
|
+
schema=schema,
|
|
492
|
+
cfg=cfg,
|
|
493
|
+
model=cfg.audio_script_model,
|
|
494
|
+
num_predict=num_predict,
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
return ScriptGenerator(
|
|
498
|
+
chat_fn=_chat,
|
|
499
|
+
model=cfg.audio_script_model,
|
|
500
|
+
max_turns=cfg.audio_max_turns if max_turns is None else max_turns,
|
|
501
|
+
max_input_tokens=cfg.audio_max_input_tokens,
|
|
502
|
+
count_tokens=_default_count_tokens,
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
# --------------------------------------------------------------------------- #
|
|
507
|
+
# TTS pluggability (Wave B)
|
|
508
|
+
# --------------------------------------------------------------------------- #
|
|
509
|
+
class TtsBackend(Protocol):
|
|
510
|
+
"""Pluggable text-to-speech backend (mirrors the ``Embedder`` Protocol).
|
|
511
|
+
|
|
512
|
+
``synthesize`` receives the in-memory script plus the path to the already
|
|
513
|
+
written ``.json`` artifact and the desired audio output path. Backends live
|
|
514
|
+
OUTSIDE this repo (Claude skills ``venice-audio-speech`` / ``speech`` /
|
|
515
|
+
``fal-lip-sync``); :class:`ShellTtsBackend` is the shell bridge.
|
|
516
|
+
"""
|
|
517
|
+
|
|
518
|
+
def synthesize(
|
|
519
|
+
self,
|
|
520
|
+
script: PodcastScript,
|
|
521
|
+
script_json_path: Path,
|
|
522
|
+
output_path: Path,
|
|
523
|
+
) -> None: ...
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
# How long a synthesis subprocess may run before it is reaped (seconds).
|
|
527
|
+
_DEFAULT_TTS_TIMEOUT = 300.0
|
|
528
|
+
|
|
529
|
+
# The ``shell:`` spec prefix selects :class:`ShellTtsBackend`.
|
|
530
|
+
_SHELL_PREFIX = "shell:"
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
class ShellTtsBackend:
|
|
534
|
+
"""Shells out to a user-supplied executable to synthesize audio.
|
|
535
|
+
|
|
536
|
+
The command (everything after ``shell:``) is split into an argv list via
|
|
537
|
+
:func:`shlex.split` at construction. At synthesis time the backend appends
|
|
538
|
+
two positional arguments — the script JSON path and the output path — and
|
|
539
|
+
runs the command with ``shell=False`` (the subprocess default), so paths
|
|
540
|
+
containing spaces are passed safely as discrete argv entries (no quoting
|
|
541
|
+
bugs, no shell injection).
|
|
542
|
+
"""
|
|
543
|
+
|
|
544
|
+
def __init__(
|
|
545
|
+
self, command_template: str, timeout: float = _DEFAULT_TTS_TIMEOUT
|
|
546
|
+
) -> None:
|
|
547
|
+
argv = shlex.split(command_template)
|
|
548
|
+
if not argv:
|
|
549
|
+
raise TtsError("TTS shell command is empty")
|
|
550
|
+
self._argv_template = argv
|
|
551
|
+
self._timeout = timeout
|
|
552
|
+
|
|
553
|
+
def synthesize(
|
|
554
|
+
self,
|
|
555
|
+
script: PodcastScript,
|
|
556
|
+
script_json_path: Path,
|
|
557
|
+
output_path: Path,
|
|
558
|
+
) -> None:
|
|
559
|
+
"""Run ``<command> <script_json_path> <output_path>`` (shell=False)."""
|
|
560
|
+
argv = [*self._argv_template, str(script_json_path), str(output_path)]
|
|
561
|
+
try:
|
|
562
|
+
subprocess.run(argv, check=True, timeout=self._timeout) # noqa: S603
|
|
563
|
+
except FileNotFoundError as exc:
|
|
564
|
+
raise TtsError(
|
|
565
|
+
f"TTS command not found: {self._argv_template[0]!r}"
|
|
566
|
+
) from exc
|
|
567
|
+
except subprocess.CalledProcessError as exc:
|
|
568
|
+
raise TtsError(
|
|
569
|
+
f"TTS command failed (exit {exc.returncode}): "
|
|
570
|
+
f"{' '.join(self._argv_template)}"
|
|
571
|
+
) from exc
|
|
572
|
+
except subprocess.TimeoutExpired as exc:
|
|
573
|
+
raise TtsError(
|
|
574
|
+
f"TTS command timed out after {self._timeout}s"
|
|
575
|
+
) from exc
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def make_tts_backend(
|
|
579
|
+
spec: str, *, timeout: float = _DEFAULT_TTS_TIMEOUT
|
|
580
|
+
) -> TtsBackend:
|
|
581
|
+
"""Build a :class:`TtsBackend` from a ``--tts`` spec string.
|
|
582
|
+
|
|
583
|
+
Currently only the ``shell:<command>`` form is supported. Future registered
|
|
584
|
+
aliases (e.g. ``venice``, ``elevenlabs``) can be added without touching the
|
|
585
|
+
Protocol. An unrecognized spec raises :class:`TtsError`.
|
|
586
|
+
"""
|
|
587
|
+
if spec.startswith(_SHELL_PREFIX):
|
|
588
|
+
return ShellTtsBackend(spec[len(_SHELL_PREFIX) :], timeout=timeout)
|
|
589
|
+
raise TtsError(
|
|
590
|
+
f"unknown TTS backend spec: {spec!r} (expected 'shell:<command>')"
|
|
591
|
+
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""One-shot data-hygiene utilities for the documents/sources/chunks tables."""
|
|
2
|
+
|
|
3
|
+
from . import search_extras as backfill_search
|
|
4
|
+
from .search_extras import BackfillReport as SearchExtrasBackfillReport
|
|
5
|
+
from .source_rows import BackfillReport, backfill_source_rows
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"BackfillReport",
|
|
9
|
+
"SearchExtrasBackfillReport",
|
|
10
|
+
"backfill_search",
|
|
11
|
+
"backfill_source_rows",
|
|
12
|
+
]
|