audient 0.1.0__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 (75) hide show
  1. audient/__init__.py +0 -0
  2. audient/_cli.py +58 -0
  3. audient/_version.py +24 -0
  4. audient/cleaning.py +104 -0
  5. audient/features.py +190 -0
  6. audient/logs.py +128 -0
  7. audient/migrations/0001_session_id.sql +31 -0
  8. audient/server.py +2122 -0
  9. audient/static/assets/index-BOr5Eso6.css +1 -0
  10. audient/static/assets/index-CU8Mh-45.js +125 -0
  11. audient/static/audio-capture-worklet.js +78 -0
  12. audient/static/favicon.svg +1 -0
  13. audient/static/icons.svg +24 -0
  14. audient/static/index.html +14 -0
  15. audient/store.py +89 -0
  16. audient/tools.py +217 -0
  17. audient-0.1.0.dist-info/METADATA +343 -0
  18. audient-0.1.0.dist-info/RECORD +75 -0
  19. audient-0.1.0.dist-info/WHEEL +4 -0
  20. audient-0.1.0.dist-info/entry_points.txt +2 -0
  21. audio_http/__init__.py +6 -0
  22. audio_http/app.py +179 -0
  23. audio_http/origins.py +41 -0
  24. audio_http/routes/__init__.py +0 -0
  25. audio_http/routes/_helpers.py +43 -0
  26. audio_http/routes/audio.py +86 -0
  27. audio_http/routes/concepts.py +143 -0
  28. audio_http/routes/concepts_writes.py +346 -0
  29. audio_http/routes/control.py +259 -0
  30. audio_http/routes/events.py +129 -0
  31. audio_http/routes/events_writes.py +226 -0
  32. audio_http/routes/growth.py +92 -0
  33. audio_http/routes/ingest.py +221 -0
  34. audio_http/routes/specialists.py +51 -0
  35. audio_http/routes/status.py +34 -0
  36. audio_http/routes/trace.py +98 -0
  37. audio_http/routes/turns.py +180 -0
  38. audio_http/schemas.py +92 -0
  39. audio_http/sse.py +143 -0
  40. classifier/__init__.py +2 -0
  41. classifier/fastagent.config.yaml +50 -0
  42. classifier/loop.py +460 -0
  43. classifier/notifications.py +9 -0
  44. classifier/trace_capture.py +126 -0
  45. classifier/transcript_capture.py +159 -0
  46. perception/__init__.py +0 -0
  47. perception/embeddings.py +107 -0
  48. perception/pipeline/__init__.py +0 -0
  49. perception/pipeline/channels.py +78 -0
  50. perception/pipeline/items.py +119 -0
  51. perception/pipeline/stages.py +495 -0
  52. perception/segmentation.py +363 -0
  53. perception/service.py +2114 -0
  54. perception/session.py +22 -0
  55. perception/sources/__init__.py +24 -0
  56. perception/sources/base.py +38 -0
  57. perception/sources/file.py +51 -0
  58. perception/sources/mic.py +55 -0
  59. perception/sources/mic_default.py +180 -0
  60. perception/sources/net.py +225 -0
  61. perception/sources/registry.py +79 -0
  62. perception/specialists/__init__.py +0 -0
  63. perception/specialists/ast/adapter.py +90 -0
  64. perception/specialists/ast/spec.yaml +75 -0
  65. perception/specialists/base.py +86 -0
  66. perception/specialists/birdnet/adapter.py +69 -0
  67. perception/specialists/birdnet/spec.yaml +65 -0
  68. perception/specialists/registry.py +104 -0
  69. perception/specialists/whisper/adapter.py +61 -0
  70. perception/specialists/whisper/spec.yaml +70 -0
  71. perception/storage/__init__.py +0 -0
  72. perception/storage/concept_memory.py +1319 -0
  73. perception/storage/events_db.py +355 -0
  74. perception/storage/persistence.py +212 -0
  75. perception/stream.py +1833 -0
audient/__init__.py ADDED
File without changes
audient/_cli.py ADDED
@@ -0,0 +1,58 @@
1
+ """Console-script entry point.
2
+
3
+ Prints a "loading" line before importing anything heavy so users see
4
+ feedback within the first tick, then imports the real ``server.main``.
5
+ Module-top imports in ``server`` pull in librosa, torch (via
6
+ transformers), fastapi, uvicorn, and fastmcp — collectively 1–3 seconds
7
+ of import time on a warm cache, longer on cold. Without this bootstrap
8
+ the user stares at a blank prompt for the full duration and can't tell
9
+ if the process is doing anything.
10
+
11
+ Also parses --log-level / --quiet / --verbose EARLY (before the heavy
12
+ imports) so logging.getLogger(...) calls at module-import time already
13
+ respect the user's chosen level. Otherwise their first N log lines
14
+ would use whatever level the default logger picked up, then flip mid-
15
+ boot when server.main() re-configures.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import logging
21
+
22
+ from .logs import configure, resolve_level
23
+
24
+
25
+ def _peek_log_flags() -> tuple[str | None, bool, bool]:
26
+ """Extract just the log-related flags from sys.argv without
27
+ committing to the full CLI parser (which lives in server.py and
28
+ can't be imported yet — that would defeat the deferred-import
29
+ point of this bootstrap)."""
30
+ p = argparse.ArgumentParser(add_help=False)
31
+ p.add_argument("--log-level", default=None)
32
+ p.add_argument("-q", "--quiet", action="store_true", default=False)
33
+ p.add_argument("-v", "--verbose", action="store_true", default=False)
34
+ args, _ = p.parse_known_args()
35
+ return args.log_level, args.quiet, args.verbose
36
+
37
+
38
+ def main() -> None:
39
+ cli_level, quiet, verbose = _peek_log_flags()
40
+ configure(level=resolve_level(
41
+ cli_level=cli_level, quiet=quiet, verbose=verbose,
42
+ ))
43
+ logger = logging.getLogger("audient")
44
+ logger.info(
45
+ "audient: loading (Python deps take a moment; "
46
+ "first run also downloads ~600MB of model weights)…"
47
+ )
48
+ # Best-effort flush so the loading line reaches the terminal
49
+ # before ~2s of blocking `from .server import ...` begins.
50
+ for h in logging.getLogger().handlers:
51
+ try:
52
+ h.flush()
53
+ except Exception:
54
+ pass
55
+
56
+ from .server import main as _server_main
57
+
58
+ _server_main()
audient/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
audient/cleaning.py ADDED
@@ -0,0 +1,104 @@
1
+ """Cleaning ops. Every op takes (samples, sample_rate) and returns new
2
+ samples — the store layer (or `invoke_specialist`) wraps results."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import librosa
7
+ import numpy as np
8
+ from scipy.signal import butter, sosfiltfilt
9
+
10
+
11
+ def bandpass(y: np.ndarray, sr: int, lo_hz: float, hi_hz: float, order: int = 4) -> np.ndarray:
12
+ nyq = sr / 2
13
+ lo = max(lo_hz, 1.0) / nyq
14
+ hi = min(hi_hz, nyq - 1.0) / nyq
15
+ if not 0 < lo < hi < 1:
16
+ raise ValueError(f"invalid band: lo={lo_hz} hi={hi_hz} sr={sr}")
17
+ sos = butter(order, [lo, hi], btype="band", output="sos")
18
+ return sosfiltfilt(sos, y).astype(y.dtype, copy=False)
19
+
20
+
21
+ def normalize(y: np.ndarray, method: str = "peak", target_dbfs: float = -3.0) -> np.ndarray:
22
+ """Normalize amplitude. `peak` scales the absolute maximum to `target_dbfs`
23
+ (default -3 dB to leave headroom); `rms` scales RMS to `target_dbfs`."""
24
+ target = 10 ** (target_dbfs / 20.0)
25
+ if method == "peak":
26
+ peak = float(np.max(np.abs(y)))
27
+ if peak < 1e-12:
28
+ return y
29
+ return (y * (target / peak)).astype(y.dtype, copy=False)
30
+ if method == "rms":
31
+ rms = float(np.sqrt(np.mean(y**2)))
32
+ if rms < 1e-12:
33
+ return y
34
+ return (y * (target / rms)).astype(y.dtype, copy=False)
35
+ raise ValueError(f"unknown normalize method: {method!r} (use 'peak' or 'rms')")
36
+
37
+
38
+ def hpss(y: np.ndarray, sr: int, component: str = "both") -> tuple[np.ndarray, np.ndarray] | np.ndarray:
39
+ """Harmonic-Percussive Source Separation (librosa). Splits audio into:
40
+ - harmonic: tonal/sustained content (speech, music, sustained tones)
41
+ - percussive: transient/click-like content (knocks, footsteps, drums)
42
+
43
+ Useful when two overlapping sources fall into different timbral
44
+ categories — e.g., a doorbell ringing while someone is talking
45
+ (speech → harmonic; doorbell ring transients → percussive).
46
+
47
+ `component`:
48
+ - `"harmonic"`: return only the harmonic component.
49
+ - `"percussive"`: return only the percussive component.
50
+ - `"both"` (default): return (harmonic, percussive) as a tuple.
51
+ """
52
+ h, p = librosa.effects.hpss(y)
53
+ if component == "harmonic":
54
+ return h.astype(y.dtype, copy=False)
55
+ if component == "percussive":
56
+ return p.astype(y.dtype, copy=False)
57
+ if component == "both":
58
+ return h.astype(y.dtype, copy=False), p.astype(y.dtype, copy=False)
59
+ raise ValueError(f"hpss component must be 'harmonic', 'percussive', or 'both'; got {component!r}")
60
+
61
+
62
+ def spectral_subtract(
63
+ signal: np.ndarray,
64
+ noise_profile: np.ndarray,
65
+ sr: int,
66
+ *,
67
+ over_subtraction: float = 1.0,
68
+ spectral_floor: float = 0.05,
69
+ n_fft: int = 2048,
70
+ hop_length: int = 512,
71
+ ) -> np.ndarray:
72
+ """Classic spectral subtraction. Given a noise-only window (`noise_profile`)
73
+ and a mixed signal window (`signal`), estimate the noise spectrum and
74
+ subtract it from the signal's magnitude spectrum bin-by-bin. Phase is
75
+ preserved from the signal.
76
+
77
+ Effective when:
78
+ - The noise is approximately stationary (rain, HVAC hum, traffic).
79
+ - You can find a noise-only stretch in the recording to use as the
80
+ profile.
81
+
82
+ Less effective when:
83
+ - The "noise" is non-stationary (music, speech).
84
+ - The signal and noise share spectral bands tightly (two voices,
85
+ two broadband mechanical sources).
86
+
87
+ Params:
88
+ `over_subtraction`: multiplier on the noise estimate. 1.0 = plain
89
+ subtraction. Higher values suppress noise more aggressively but
90
+ introduce musical-noise artifacts.
91
+ `spectral_floor`: minimum residual ratio (0..1) — bins are floored at
92
+ `spectral_floor * |signal_bin|` to avoid sharp zeros that cause
93
+ artifacts. 0.05 is a conservative default.
94
+ """
95
+ S_sig = librosa.stft(signal.astype(np.float32), n_fft=n_fft, hop_length=hop_length)
96
+ S_noise = librosa.stft(noise_profile.astype(np.float32), n_fft=n_fft, hop_length=hop_length)
97
+ mag_sig = np.abs(S_sig)
98
+ phase_sig = np.angle(S_sig)
99
+ mag_noise = np.abs(S_noise).mean(axis=1, keepdims=True)
100
+ subtracted = mag_sig - over_subtraction * mag_noise
101
+ mag_clean = np.maximum(subtracted, spectral_floor * mag_sig)
102
+ S_clean = mag_clean * np.exp(1j * phase_sig)
103
+ y_clean = librosa.istft(S_clean, n_fft=n_fft, hop_length=hop_length, length=len(signal))
104
+ return y_clean.astype(signal.dtype, copy=False)
audient/features.py ADDED
@@ -0,0 +1,190 @@
1
+ """Symbolic feature extraction for audio clips.
2
+
3
+ Functions accept a (samples, sample_rate) pair, never an audio_id — keeps
4
+ this module testable without the store.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ import librosa
12
+ import numpy as np
13
+ from scipy.signal import find_peaks
14
+
15
+
16
+ def _round(x: float, n: int = 3) -> float:
17
+ return float(np.round(float(x), n))
18
+
19
+
20
+ def spectral_peaks(y: np.ndarray, sr: int, top_n: int = 5) -> list[dict[str, float]]:
21
+ if len(y) < 1024:
22
+ return []
23
+ spec = np.abs(np.fft.rfft(y * np.hanning(len(y))))
24
+ freqs = np.fft.rfftfreq(len(y), 1 / sr)
25
+ spec_db = 20 * np.log10(spec + 1e-12)
26
+ min_dist = max(5, int(50 / (sr / len(y))))
27
+ peaks, props = find_peaks(spec_db, prominence=6.0, distance=min_dist)
28
+ if len(peaks) == 0:
29
+ return []
30
+ order = np.argsort(props["prominences"])[::-1][:top_n]
31
+ return [
32
+ {
33
+ "freq_hz": _round(freqs[peaks[i]], 1),
34
+ "magnitude_db": _round(spec_db[peaks[i]] - spec_db.max(), 1),
35
+ "prominence_db": _round(props["prominences"][i], 1),
36
+ }
37
+ for i in order
38
+ ]
39
+
40
+
41
+ def mains_hum(peaks: list[dict[str, float]]) -> dict[str, float] | None:
42
+ """Detect 50/60 Hz mains leakage including 2nd/3rd harmonics.
43
+
44
+ A harmonic peak alone is not sufficient evidence — male vocal F0
45
+ (~120–200 Hz) overlaps the 120/150/180 Hz harmonic bands. Only claim
46
+ a mains family when the fundamental (50 or 60 Hz) is also present
47
+ with non-trivial prominence."""
48
+ def near(freq: float, target: float, tol: float = 3.0) -> bool:
49
+ return abs(freq - target) <= tol
50
+
51
+ fund_50 = next((p for p in peaks if near(p["freq_hz"], 50) and p["prominence_db"] >= 6), None)
52
+ fund_60 = next((p for p in peaks if near(p["freq_hz"], 60) and p["prominence_db"] >= 6), None)
53
+ if not fund_50 and not fund_60:
54
+ return None
55
+
56
+ fundamental = fund_50 or fund_60
57
+ f0 = 50.0 if fund_50 else 60.0
58
+ harmonics = []
59
+ for mult in (2, 3, 4):
60
+ match = next((p for p in peaks if near(p["freq_hz"], f0 * mult)), None)
61
+ if match:
62
+ harmonics.append({"freq_hz": match["freq_hz"], "prominence_db": match["prominence_db"]})
63
+
64
+ return {
65
+ "fundamental_hz": fundamental["freq_hz"],
66
+ "prominence_db": fundamental["prominence_db"],
67
+ "family_hz": f0,
68
+ "harmonics": harmonics,
69
+ }
70
+
71
+
72
+ def voicing(y: np.ndarray, sr: int) -> dict[str, float]:
73
+ """Two stable, interpretable voicing features:
74
+ - `harmonic_ratio`: fraction of signal energy in the harmonic (vs
75
+ percussive) component. Speech, sustained instruments, and bird songs
76
+ all read high here.
77
+ - `zero_crossing_rate`: time-domain rate of sign changes. Voiced speech
78
+ sits in ~0.02-0.15; fricatives and noise push it higher.
79
+
80
+ Computed on energetic frames only (silence trimmed) so pauses don't
81
+ suppress the harmonic ratio. For an actual speech-vs-not decision call
82
+ `vad` instead — it does frame-level analysis with proper segments.
83
+ """
84
+ y_trim, _ = librosa.effects.trim(y, top_db=30)
85
+ if len(y_trim) < sr // 10:
86
+ y_trim = y
87
+ harm, _ = librosa.effects.hpss(y_trim)
88
+ harm_ratio = float(np.sum(harm**2) / (np.sum(y_trim**2) + 1e-12))
89
+ zcr = float(librosa.feature.zero_crossing_rate(y_trim).mean())
90
+ return {
91
+ "harmonic_ratio": _round(harm_ratio),
92
+ "zero_crossing_rate": _round(zcr),
93
+ }
94
+
95
+
96
+ def temporal_stability(y: np.ndarray, sr: int) -> dict[str, float]:
97
+ centroid_t = librosa.feature.spectral_centroid(y=y, sr=sr)[0]
98
+ rms_t = librosa.feature.rms(y=y, frame_length=2048, hop_length=512)[0]
99
+ rms_t_db = 20 * np.log10(rms_t + 1e-12)
100
+ half = len(y) // 2
101
+ peaks_a = spectral_peaks(y[:half], sr, top_n=1) if half > 1024 else []
102
+ peaks_b = spectral_peaks(y[half:], sr, top_n=1) if half > 1024 else []
103
+ peak_drift = (
104
+ abs(peaks_a[0]["freq_hz"] - peaks_b[0]["freq_hz"])
105
+ if peaks_a and peaks_b else 0.0
106
+ )
107
+ centroid_cv = float(centroid_t.std() / (centroid_t.mean() + 1e-12))
108
+ return {
109
+ "centroid_std_hz": _round(float(centroid_t.std()), 1),
110
+ "centroid_cv": _round(centroid_cv),
111
+ "rms_std_db": _round(float(rms_t_db.std()), 1),
112
+ "peak_drift_hz": _round(peak_drift, 1),
113
+ }
114
+
115
+
116
+ def clipping_analysis(y: np.ndarray) -> dict[str, Any]:
117
+ """Plateau-based clipping detection. Single full-scale samples (a typical
118
+ normalized peak) don't count — real clipping holds the cap for multiple
119
+ consecutive samples because the original signal was above the cap and got
120
+ truncated to a flat line. 3-sample minimum follows pro-audio convention
121
+ (foobar2000; Adobe Audition uses 4). Returns the supporting data so the
122
+ agent can reason about borderline cases itself."""
123
+ at_full = np.abs(y) >= 0.999
124
+ n_clipped = int(at_full.sum())
125
+ fraction = float(n_clipped / len(y)) if len(y) else 0.0
126
+
127
+ longest_run = 0
128
+ cur = 0
129
+ for v in at_full:
130
+ if v:
131
+ cur += 1
132
+ if cur > longest_run:
133
+ longest_run = cur
134
+ else:
135
+ cur = 0
136
+
137
+ return {
138
+ "n_clipped_samples": n_clipped,
139
+ "fraction": _round(fraction, 6),
140
+ "longest_run_samples": int(longest_run),
141
+ "clipping_likely": bool(longest_run >= 3),
142
+ }
143
+
144
+
145
+ def summarize(y: np.ndarray, sr: int) -> dict[str, Any]:
146
+ """High-level overview — what `summarize(audio_id)` returns."""
147
+ duration = len(y) / sr
148
+ peaks = spectral_peaks(y, sr, top_n=5)
149
+ rms = float(np.sqrt(np.mean(y**2)))
150
+ peak_amp = float(np.max(np.abs(y)))
151
+ frame_rms = librosa.feature.rms(y=y, frame_length=2048, hop_length=512)[0]
152
+ noise_floor_db = float(20 * np.log10(np.percentile(frame_rms, 10) + 1e-12))
153
+ signal_db = float(20 * np.log10(np.percentile(frame_rms, 95) + 1e-12))
154
+ return {
155
+ "duration_s": _round(duration, 2),
156
+ "sample_rate": int(sr),
157
+ "rms": _round(rms, 4),
158
+ "peak_amplitude": _round(peak_amp, 4),
159
+ "noise_floor_dbfs": _round(noise_floor_db, 1),
160
+ "snr_db": _round(signal_db - noise_floor_db, 1),
161
+ "dominant_peaks": peaks[:3],
162
+ "mains_hum": mains_hum(peaks),
163
+ "clipping": clipping_analysis(y),
164
+ }
165
+
166
+
167
+ def spectrum(y: np.ndarray, sr: int) -> dict[str, Any]:
168
+ """What `spectrum(audio_id, ...)` returns."""
169
+ centroid = float(librosa.feature.spectral_centroid(y=y, sr=sr).mean())
170
+ rolloff = float(librosa.feature.spectral_rolloff(y=y, sr=sr, roll_percent=0.85).mean())
171
+ bandwidth = float(librosa.feature.spectral_bandwidth(y=y, sr=sr).mean())
172
+ flatness = float(librosa.feature.spectral_flatness(y=y).mean())
173
+ return {
174
+ "centroid_hz": _round(centroid, 1),
175
+ "rolloff_85pct_hz": _round(rolloff, 1),
176
+ "bandwidth_hz": _round(bandwidth, 1),
177
+ "flatness": _round(flatness),
178
+ "peaks": spectral_peaks(y, sr, top_n=5),
179
+ "temporal": temporal_stability(y, sr),
180
+ }
181
+
182
+
183
+ def stats(y: np.ndarray, sr: int) -> dict[str, Any]:
184
+ """What `stats(audio_id, ...)` returns."""
185
+ kurt = float(((y - y.mean()) ** 4).mean() / (y.std() ** 4 + 1e-12) - 3)
186
+ return {
187
+ "rms": _round(float(np.sqrt(np.mean(y**2))), 4),
188
+ "kurtosis_excess": _round(kurt, 2),
189
+ "voicing": voicing(y, sr),
190
+ }
audient/logs.py ADDED
@@ -0,0 +1,128 @@
1
+ """Central logging setup.
2
+
3
+ Every module that emits diagnostics does::
4
+
5
+ from audient.logs import get_logger
6
+ logger = get_logger(__name__)
7
+ logger.info("stream: CLAP loaded (17.4s)")
8
+
9
+ Output shape (default INFO level)::
10
+
11
+ 20:45:12.847 INFO stream: CLAP loaded (17.4s)
12
+ 20:45:13.201 WARNING arbiter stop: RuntimeError: ...
13
+ 20:45:13.202 ERROR arbiter task died: cannot reuse coroutine
14
+
15
+ `configure()` is called once from ``audient._cli`` (or by tests that
16
+ need a specific level). Level resolution priority — first non-None
17
+ wins:
18
+
19
+ 1. explicit CLI arg (``--log-level``, ``--quiet``, ``--verbose``)
20
+ 2. ``AUDIENT_LOG_LEVEL`` env var (case-insensitive)
21
+ 3. default: ``INFO``
22
+
23
+ Third-party loggers (fast-agent, uvicorn, httpx, transformers,
24
+ huggingface_hub) get their own level set to WARNING unless the user
25
+ opts into DEBUG — otherwise the transcript is dominated by their
26
+ noise on every model load.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import logging
31
+ import os
32
+ import sys
33
+ from typing import Literal
34
+
35
+ _LEVELS = ("debug", "info", "warning", "error")
36
+ LevelName = Literal["debug", "info", "warning", "error"]
37
+
38
+ _configured = False
39
+
40
+
41
+ def _level_from_name(name: str) -> int:
42
+ return getattr(logging, name.upper(), logging.INFO)
43
+
44
+
45
+ def resolve_level(
46
+ *,
47
+ cli_level: str | None = None,
48
+ quiet: bool = False,
49
+ verbose: bool = False,
50
+ ) -> int:
51
+ """CLI > env > default. Returns a numeric logging level.
52
+
53
+ ``--quiet`` and ``--verbose`` are shortcuts. Explicit
54
+ ``--log-level`` overrides them if both are given.
55
+ """
56
+ if cli_level:
57
+ return _level_from_name(cli_level)
58
+ if quiet:
59
+ return logging.WARNING
60
+ if verbose:
61
+ return logging.DEBUG
62
+ env = os.environ.get("AUDIENT_LOG_LEVEL")
63
+ if env:
64
+ return _level_from_name(env)
65
+ return logging.INFO
66
+
67
+
68
+ def configure(level: int = logging.INFO) -> None:
69
+ """Idempotent root-logger setup with a stderr handler.
70
+
71
+ Repeat calls change the level but do not attach duplicate handlers.
72
+ """
73
+ global _configured
74
+ root = logging.getLogger()
75
+ if not _configured:
76
+ handler = logging.StreamHandler(sys.stderr)
77
+ # `%(asctime)s` alone gives second-resolution; we want ms for
78
+ # perf diagnostics (model warm timings, ingestion cadence, arbiter
79
+ # decisions). Use `%(msecs)03.0f` on the same record for the
80
+ # sub-second slice.
81
+ handler.setFormatter(logging.Formatter(
82
+ fmt="%(asctime)s.%(msecs)03d %(levelname)-8s %(message)s",
83
+ datefmt="%H:%M:%S",
84
+ ))
85
+ root.addHandler(handler)
86
+ _configured = True
87
+ root.setLevel(level)
88
+
89
+ # Third-party noise: pin to WARNING unless we're in DEBUG. The
90
+ # user set the level on THEIR code — they don't want CLAP's
91
+ # tokenizer chatter or uvicorn's per-request access lines under
92
+ # a plain `--verbose`.
93
+ third_party_level = logging.DEBUG if level <= logging.DEBUG else logging.WARNING
94
+ for name in (
95
+ "uvicorn",
96
+ "uvicorn.error",
97
+ "uvicorn.access",
98
+ "httpx",
99
+ "httpcore",
100
+ "transformers",
101
+ "huggingface_hub",
102
+ "urllib3",
103
+ "asyncio",
104
+ # FastMCP / mcp SDK internal chatter. Per-request lines like
105
+ # "Processing request of type ListToolsRequest" and
106
+ # "Created new transport with session ID: <uuid>" and the
107
+ # classifier's periodic "Processing request of type
108
+ # PingRequest" all land here at INFO. Suppressed unless the
109
+ # user opts into DEBUG.
110
+ "mcp",
111
+ "mcp.server",
112
+ "mcp.server.lowlevel",
113
+ "mcp.server.streamable_http",
114
+ "fastmcp",
115
+ # fast-agent-mcp client side.
116
+ "fast_agent",
117
+ ):
118
+ logging.getLogger(name).setLevel(third_party_level)
119
+
120
+
121
+ def get_logger(name: str) -> logging.Logger:
122
+ """Preferred accessor. Mirrors ``logging.getLogger`` — kept as a
123
+ thin re-export so we have a single import surface for future
124
+ migrations (e.g. structured logging)."""
125
+ return logging.getLogger(name)
126
+
127
+
128
+ __all__ = ["configure", "get_logger", "resolve_level", "LevelName"]
@@ -0,0 +1,31 @@
1
+ -- Phase 02.4 Plan 03 — add session_id to the events table.
2
+ --
3
+ -- session_id is the wire-level discriminator that Phase 02.4 hard-codes
4
+ -- to 'default' everywhere. Adding it as a NOT NULL column with a
5
+ -- DEFAULT 'default' clause is the canonical SQLite ADD-COLUMN shape:
6
+ -- atomic, backward-compatible (existing rows get the default), and
7
+ -- preserves the events table's primary-key shape.
8
+ --
9
+ -- event_embeddings is a sqlite-vec `vec0` virtual table (see
10
+ -- concept_memory._init_schema CREATE VIRTUAL TABLE ... USING vec0(...)).
11
+ -- vec0 does NOT support ALTER. We track session_id via a sidecar
12
+ -- table keyed by event_id, backfilled from the events table the
13
+ -- first time this migration runs. event_embeddings_session is the
14
+ -- canonical mapping; future queries that need the session id of an
15
+ -- embedding row JOIN through this table.
16
+ --
17
+ -- Idempotence is guaranteed by the schema_migrations table in
18
+ -- events_db.run_pending_migrations — each filename is recorded once
19
+ -- on first apply, and a re-run is a no-op.
20
+
21
+ ALTER TABLE events ADD COLUMN session_id TEXT NOT NULL DEFAULT 'default';
22
+
23
+ CREATE INDEX IF NOT EXISTS events_session_id_idx ON events (session_id);
24
+
25
+ CREATE TABLE IF NOT EXISTS event_embeddings_session (
26
+ event_id TEXT PRIMARY KEY,
27
+ session_id TEXT NOT NULL DEFAULT 'default'
28
+ );
29
+
30
+ INSERT OR IGNORE INTO event_embeddings_session (event_id, session_id)
31
+ SELECT event_id, 'default' FROM events;