Skip to content

Commit f8b3b98

Browse files
committed
fix(ci): make the backend suite importable and green from a cold checkout
The previous commit referenced code that only existed in the working tree, so CI could not import the modules it changed. Staging by hand cannot see that: locally every symbol resolves. - constants, sse_publisher, conversation_utils, forced_alignment, vault_verify and timeline/discovery supply symbols the previous commit imports (verify_day_episode_ranges, TITLE_NOT_GENERATED, publish_sse_event_async, generate_conversation_title, generate_short_summary, estimate_words_from_segment_timing). Without them collection failed on every test module that reaches the memory provider. - test_streaming_persistence_invariant patched publish_sse_event, which the controller no longer exposes at module level. Two failures CI had and a local run cannot reproduce: - mask_string's docstring carried \w and \S in a non-raw string. That is a SyntaxWarning, which pytest's filterwarnings=error promotes to a SyntaxError — but only on an uncached import, so a warm .pyc hides it locally and a cold CI checkout does not. Made the docstring raw; it was the only such literal in the backend. - Fixing that unmasked a pre-existing gap it had been aborting before: test_memory_setup_wizard loads backends/advanced/init.py, which imports chronicle_setup, and tests/unit imports edge/service_manager.py, which imports fastapi and uvicorn. None are in the test env. They are supplied on the uv command line rather than as path deps, because ../../extras is outside the Docker build context. Verified in a clean worktree with the CI environment rather than the development one: 1050 backend tests and 120 root tooling tests pass.
1 parent 0d4c322 commit f8b3b98

9 files changed

Lines changed: 435 additions & 159 deletions

File tree

.github/workflows/python-tests.yml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,16 @@ jobs:
5151
version: "latest"
5252

5353
- name: Run unit tests with coverage
54+
# fastapi/uvicorn are the node agent's own runtime deps: tests/unit imports
55+
# edge/service_manager.py, which serves the WebUI's service controls. They are
56+
# not in setup-requirements.txt, which covers the wizard rather than the agent.
5457
run: >-
5558
uv run
5659
--with-requirements setup-requirements.txt
5760
--with pytest
5861
--with pytest-cov
62+
--with fastapi
63+
--with uvicorn
5964
pytest tests/unit
6065
--cov
6166
--cov-config=.coveragerc
@@ -153,8 +158,15 @@ jobs:
153158
run: uv sync --locked --group test
154159

155160
- name: Run unit tests with coverage
161+
# chronicle_setup comes in on the command line rather than as a path dep:
162+
# tests/test_memory_setup_wizard.py loads backends/advanced/init.py, which
163+
# imports it, but ../../extras is outside the Docker build context, so
164+
# declaring it in pyproject.toml would break the image build. Same reasoning
165+
# as the chronicle-wearable-sdk git source there.
156166
run: >-
157-
uv run --group test pytest
167+
uv run --group test
168+
--with ../../extras/chronicle-setup
169+
pytest
158170
--cov=advanced_omi_backend
159171
--cov-report=term-missing
160172
--cov-report=xml

backends/advanced/src/advanced_omi_backend/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
OMI_CHANNELS = 1
55
OMI_SAMPLE_WIDTH = 2 # bytes (16‑bit)
66

7+
# A missing generated title must remain visibly machine-detectable. Never replace it
8+
# with transcript text or a plausible label such as "Recording"/"Conversation".
9+
TITLE_NOT_GENERATED = "[title not generated]"
10+
711
# Reserved diarization label for segments triaged as background/noise (TV, media,
812
# ambient) rather than a real person. Used by the Data Audit speaker-triage flow:
913
# applying it sets the segment's speaker to this label AND reclassifies it to a

backends/advanced/src/advanced_omi_backend/services/forced_alignment.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,30 @@ async def align_audio_words(
109109
f"({data.get('aligned_segments')}/{data.get('total_segments')} segments aligned)"
110110
)
111111
return words
112+
113+
114+
def estimate_words_from_segment_timing(segments: List[Dict]) -> List[Dict]:
115+
"""Create monotonic word clocks when the neural aligner cannot align text.
116+
117+
Segment timestamps remain authoritative; this only distributes their words
118+
uniformly inside each segment so Pyannote speaker turns can receive the existing
119+
transcript text. It is deliberately a last resort after forced alignment.
120+
"""
121+
words: List[Dict] = []
122+
for segment in segments:
123+
start = float(segment.get("start", 0.0))
124+
end = float(segment.get("end", start))
125+
tokens = str(segment.get("text", "")).split()
126+
if not tokens or end <= start:
127+
continue
128+
step = (end - start) / len(tokens)
129+
for index, token in enumerate(tokens):
130+
words.append(
131+
{
132+
"word": token,
133+
"start": start + index * step,
134+
"end": start + (index + 1) * step,
135+
"confidence": 0.0,
136+
}
137+
)
138+
return words

backends/advanced/src/advanced_omi_backend/services/memory/vault_verify.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@
3232
from .vault_scaffold import VaultPathError, safe_vault_relative_path
3333

3434
_H2_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE)
35+
_DAY_DIGEST_RANGE_RE = re.compile(r"^###\s+(\d{2}:\d{2}–\d{2}:\d{2})\s+·", re.MULTILINE)
36+
_DAY_NOTE_RANGE_RE = re.compile(
37+
r"^-\s+(?:\*\*)?(\d{2}:\d{2}–\d{2}:\d{2})(?:\*\*)?\s+·",
38+
re.MULTILINE,
39+
)
3540

3641
# Long-lived structured notes carry a stable spine plus the aggregation embed that
3742
# auto-lists their conversations. A note missing either is malformed forever.
@@ -148,6 +153,47 @@ def illegal_path_reason(rel: str) -> str:
148153
return ""
149154

150155

156+
def root_note_role_reason(
157+
root: Path, rel: str, before: str | None, content: str
158+
) -> str:
159+
"""Why a changed root Markdown note is not a valid category hub.
160+
161+
Content notes live one folder deep. Root Markdown is reserved for the thin hub
162+
notes that make category wikilinks resolve and embed their matching Obsidian Base.
163+
Organic categories remain open-ended, but they must be created as the complete
164+
template/base/hub bundle rather than by dropping an entity or topic at the root.
165+
"""
166+
167+
path = Path(rel)
168+
if len(path.parts) != 1 or path.suffix != ".md":
169+
return ""
170+
171+
category = path.stem
172+
if before is not None:
173+
return (
174+
"root Markdown files are category hubs, not captured-content notes. Do not "
175+
"edit the hub; put durable content in its category folder (for a topic, "
176+
f"`Topics/{category}.md`)."
177+
)
178+
179+
template = root / "Templates" / f"{category} Template.md"
180+
base = root / "Templates" / "Bases" / f"{category}.base"
181+
is_complete_hub = (
182+
template.is_file()
183+
and base.is_file()
184+
and f"# {category}" in content
185+
and f"![[{category}.base]]" in content
186+
)
187+
if is_complete_hub:
188+
return ""
189+
return (
190+
"root Markdown files are reserved for category hubs created as a matching "
191+
"template/base/hub bundle. If this is a topic, move it to "
192+
f"`Topics/{category}.md`; if it is a new recurring kind of thing, create the "
193+
"category first and file the note under `<Category>/<Title>.md`."
194+
)
195+
196+
151197
def _markdown_files(root: Path) -> Dict[str, str]:
152198
"""Every readable ``*.md`` in the vault, keyed by vault-relative POSIX path."""
153199

@@ -163,6 +209,52 @@ def _markdown_files(root: Path) -> Dict[str, str]:
163209
return out
164210

165211

212+
def verify_day_episode_ranges(note_path: Path, day_digest: str) -> List[Finding]:
213+
"""Require the Daily episode index to mirror the active timeline exactly.
214+
215+
A day can be analysed again after it was already written. The write agent used to
216+
interpret "add only what is missing" literally: it appended a newly discovered
217+
episode but retained stale time ranges for every existing episode. The write then
218+
looked healthy even though the vault no longer represented the active run.
219+
220+
The semantic wording stays agentic, but the episode index is a source-backed
221+
contract: one ordered bullet per supplied episode, with the exact range selected by
222+
segmentation. Raw transcripts remain outside the vault.
223+
"""
224+
225+
expected = _DAY_DIGEST_RANGE_RE.findall(day_digest or "")
226+
try:
227+
note = note_path.read_text(encoding="utf-8")
228+
except OSError:
229+
note = ""
230+
231+
episodes_heading = re.search(r"^##\s+Episodes\s*$", note, re.MULTILINE)
232+
if episodes_heading is None:
233+
section = ""
234+
else:
235+
section_start = episodes_heading.end()
236+
next_heading = _H2_RE.search(note, section_start)
237+
section_end = next_heading.start() if next_heading else len(note)
238+
section = note[section_start:section_end]
239+
actual = _DAY_NOTE_RANGE_RE.findall(section)
240+
241+
if actual == expected:
242+
return []
243+
244+
rel = "/".join(note_path.parts[-2:])
245+
return [
246+
Finding(
247+
rel,
248+
"episode_ranges",
249+
"replace only the `## Episodes` section with exactly one chronological "
250+
"bullet per supplied day episode, using each source range verbatim and "
251+
"removing every stale or duplicate bullet. "
252+
f"Expected {len(expected)} range(s): {', '.join(expected) or '(none)'}. "
253+
f"Found {len(actual)}: {', '.join(actual) or '(none)' }.",
254+
)
255+
]
256+
257+
166258
def verify_vault_changes(
167259
root: Path,
168260
before: Mapping[str, str],
@@ -232,6 +324,10 @@ def verify_vault_changes(
232324
if reason:
233325
findings.append(Finding(rel, "illegal_path", reason))
234326

327+
reason = root_note_role_reason(root, rel, was, content)
328+
if reason:
329+
findings.append(Finding(rel, "root_note_role", reason))
330+
235331
reason = non_person_note_reason(rel)
236332
if reason:
237333
findings.append(Finding(rel, "not_a_person", reason))

backends/advanced/src/advanced_omi_backend/services/sse_publisher.py

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,18 @@
1414
import time
1515

1616
import redis
17+
import redis.asyncio as aioredis
1718

18-
from advanced_omi_backend.redis_factory import create_sync_redis
19+
from advanced_omi_backend.redis_factory import create_async_redis, create_sync_redis
1920

2021
logger = logging.getLogger(__name__)
2122

2223
# Lazy-initialized sync Redis client (for RQ workers)
2324
_sync_redis: redis.Redis | None = None
2425

26+
# Lazy-initialized async client, for callers already on an event loop.
27+
_async_redis: aioredis.Redis | None = None
28+
2529

2630
def _get_sync_redis() -> redis.Redis:
2731
"""Get or create the sync Redis client."""
@@ -31,6 +35,18 @@ def _get_sync_redis() -> redis.Redis:
3135
return _sync_redis
3236

3337

38+
def _get_async_redis() -> aioredis.Redis:
39+
"""Get or create the async Redis client. Must be called from the loop."""
40+
global _async_redis
41+
if _async_redis is None:
42+
_async_redis = create_async_redis(decode_responses=True)
43+
return _async_redis
44+
45+
46+
def _message(event_type: str, data: dict) -> str:
47+
return json.dumps({"event": event_type, "data": data, "timestamp": time.time()})
48+
49+
3450
def publish_sse_event(user_id: str, event_type: str, data: dict) -> None:
3551
"""
3652
Publish an SSE event to the user's channel (sync, for RQ workers).
@@ -42,19 +58,28 @@ def publish_sse_event(user_id: str, event_type: str, data: dict) -> None:
4258
"""
4359
try:
4460
r = _get_sync_redis()
45-
message = json.dumps(
46-
{
47-
"event": event_type,
48-
"data": data,
49-
"timestamp": time.time(),
50-
}
51-
)
52-
r.publish(f"sse:{user_id}", message)
61+
r.publish(f"sse:{user_id}", _message(event_type, data))
5362
except Exception:
5463
# SSE publishing is best-effort — never fail the calling job
5564
logger.debug("Failed to publish SSE event %s", event_type, exc_info=True)
5665

5766

67+
async def publish_sse_event_async(user_id: str, event_type: str, data: dict) -> None:
68+
"""Publish an SSE event from a caller already on the event loop.
69+
70+
The sync client above must not be used there. Its first command on a dropped
71+
connection reconnects inline — a blocking ``getaddrinfo`` and ``connect`` on
72+
the loop thread, measured here at 3-5s per reconnect, during which nothing
73+
else in the process runs.
74+
"""
75+
try:
76+
r = _get_async_redis()
77+
await r.publish(f"sse:{user_id}", _message(event_type, data))
78+
except Exception:
79+
# SSE publishing is best-effort — never fail the calling handler
80+
logger.debug("Failed to publish SSE event %s", event_type, exc_info=True)
81+
82+
5883
# Throttle state for publish_sse_event_throttled
5984
_last_publish: dict[str, float] = {}
6085

0 commit comments

Comments
 (0)