-
Notifications
You must be signed in to change notification settings - Fork 2.2k
feat(memory): verbatim transcript-chunk retrieval (flag-gated) #7832
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d82d054
ffe7278
07cb88f
5690bc4
b8df931
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,7 +21,9 @@ | |
| from fastapi import APIRouter, Depends, Query | ||
| from pydantic import BaseModel, Field | ||
|
|
||
| import database.vector_db as vector_db | ||
| from utils.other.endpoints import get_current_user_uid, with_rate_limit | ||
| from utils.conversations.transcript_chunks import hydrate_chunk_texts | ||
| from utils.retrieval.tool_services.conversations import get_conversations_text, search_conversations_text | ||
| from utils.retrieval.tool_services.memories import get_memories_text, search_memories_text | ||
| from utils.retrieval.tool_services.action_items import ( | ||
|
|
@@ -115,6 +117,32 @@ def search_conversations( | |
| return _ok("search_conversations", result) | ||
|
|
||
|
|
||
| class SearchChunksRequest(BaseModel): | ||
| query: str = Field(description="Semantic search query") | ||
| limit: int = Field(default=20, ge=1, le=30) | ||
|
|
||
|
|
||
| @router.post("/v1/tools/conversations/search-chunks", response_model=ToolResponse) | ||
| def search_conversation_chunks( | ||
| body: SearchChunksRequest, | ||
| uid: str = Depends(with_rate_limit(get_current_user_uid, "tools:search")), | ||
| ): | ||
| """Semantic search over RAW transcript chunks (verbatim evidence with dates). | ||
|
|
||
| Complements /conversations/search, which matches against conversation summaries: | ||
| summaries drop specifics (exact dates, names, numbers), so detail questions need | ||
| this verbatim layer. Returns chunks newest-relevant with their conversation date. | ||
| """ | ||
| rows = vector_db.search_transcript_chunks(uid, body.query, limit=body.limit) | ||
| rows = hydrate_chunk_texts(uid, rows) | ||
| if not rows: | ||
| return _ok("search_conversation_chunks", f"No transcript excerpts found matching '{body.query}'.") | ||
| parts = [] | ||
| for i, r in enumerate(rows, 1): | ||
| parts.append(f"Excerpt {i} (relevance: {r['score']:.2f}):\n{r['text']}") | ||
| return _ok("search_conversation_chunks", "\n\n".join(parts)) | ||
|
|
||
|
|
||
| # --------------- memory endpoints --------------- | ||
|
|
||
|
|
||
|
Comment on lines
+130
to
148
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
A simple guard at the top of the handler, or inside |
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| """Build verbatim transcript chunks for vector indexing. | ||
|
|
||
| Conversation vectors (ns1) embed only the structured summary, so specific details | ||
| (exact dates, names, numbers, one-off mentions) are unfindable semantically. These | ||
| chunks slice the raw transcript into overlapping windows, each prefixed with the | ||
| conversation date, so semantic search can land on the verbatim evidence. | ||
| """ | ||
|
|
||
| from datetime import datetime | ||
| from typing import List, Optional | ||
|
|
||
| import database.conversations as conversations_db | ||
|
|
||
| # ~8 segments per chunk with 2-segment overlap keeps chunks small enough to embed | ||
| # precisely while not splitting answers across a hard boundary. | ||
| CHUNK_WINDOW = 8 | ||
| CHUNK_STRIDE = 6 | ||
|
|
||
|
|
||
| def _speaker_label(seg: dict, people_by_id: Optional[dict] = None) -> str: | ||
| if seg.get('is_user'): | ||
| return 'User' | ||
| person_id = seg.get('person_id') | ||
| if person_id and people_by_id and person_id in people_by_id: | ||
| return people_by_id[person_id] | ||
| speaker_id = seg.get('speaker_id') | ||
| return f"Speaker {speaker_id}" if speaker_id is not None else 'Speaker' | ||
|
|
||
|
|
||
| def build_transcript_chunks( | ||
| segments: List[dict], | ||
| started_at: Optional[datetime], | ||
| window: int = CHUNK_WINDOW, | ||
| stride: int = CHUNK_STRIDE, | ||
| people_by_id: Optional[dict] = None, | ||
| ) -> List[dict]: | ||
| """segments: transcript_segment dicts ({'text','is_user','speaker_id','person_id',...}). | ||
|
|
||
| Returns [{'text', 'created_at' (unix ts), 'chunk_index'}] ready for | ||
| vector_db.upsert_transcript_chunk_vectors. | ||
| """ | ||
| lines = [] | ||
| for seg in segments or []: | ||
| text = (seg.get('text') or '').strip() | ||
| if not text: | ||
| continue | ||
| lines.append(f"{_speaker_label(seg, people_by_id)}: {text}") | ||
| if not lines: | ||
| return [] | ||
|
|
||
| date_header = '' | ||
| created_ts = 0 | ||
| if started_at is not None: | ||
| date_header = f"[Conversation on {started_at.strftime('%d %b %Y, %H:%M')}]\n" | ||
| created_ts = int(started_at.timestamp()) | ||
|
|
||
| chunks = [] | ||
| idx = 0 | ||
| pos = 0 | ||
| while pos < len(lines): | ||
| piece = lines[pos : pos + window] | ||
| chunks.append( | ||
| { | ||
| 'text': date_header + "\n".join(piece), | ||
| 'created_at': created_ts, | ||
| 'chunk_index': idx, | ||
| } | ||
| ) | ||
| if pos + window >= len(lines): | ||
| break | ||
| pos += stride | ||
| idx += 1 | ||
| return chunks | ||
|
|
||
|
|
||
| def hydrate_chunk_texts(uid: str, rows: List[dict]) -> List[dict]: | ||
| """Attach verbatim text to chunk references returned by vector search. | ||
|
|
||
| Re-reads the conversations from Firestore (decrypted by the db layer) and rebuilds | ||
| the deterministic chunking, so transcript text never has to live in Pinecone. | ||
| Rows whose conversation/chunk no longer exists are dropped. | ||
| """ | ||
| conv_ids = list({r['conversation_id'] for r in rows if r.get('conversation_id')}) | ||
| if not conv_ids: | ||
| return [] | ||
| conversations = conversations_db.get_conversations_by_id(uid, conv_ids) | ||
| chunks_by_conv = {} | ||
| for c in conversations: | ||
| segs = c.get('transcript_segments') or [] | ||
| started = c.get('started_at') or c.get('created_at') | ||
| chunks_by_conv[c['id']] = {ch['chunk_index']: ch['text'] for ch in build_transcript_chunks(segs, started)} | ||
|
Comment on lines
+88
to
+91
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| hydrated = [] | ||
| for r in rows: | ||
| text = chunks_by_conv.get(r.get('conversation_id'), {}).get(r.get('chunk_index')) | ||
| if text: | ||
| hydrated.append({**r, 'text': text}) | ||
| return hydrated | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
listcall on every conversation deletiondelete_transcript_chunk_vectorsis called regardless ofTRANSCRIPT_CHUNK_INDEXING_ENABLED. When the flag is off (default) and no chunks are indexed,index.list(prefix=..., namespace=TRANSCRIPT_CHUNKS_NAMESPACE)still makes a network round-trip to Pinecone on every single conversation deletion. The same pattern is repeated inusers.pyfor account deletion, where it fires once per conversation ID in the user's history. The PR's "no production behavior change" claim is therefore inaccurate: every deletion in the current release incurs an extra Pinecone API call.