8000 feat(search): find and filter moments by sound events like laughter or applause by adeelahmad · Pull Request #670 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,12 +172,13 @@ listed under [In progress](#in-progress), and what comes next under [Planned](#p
asset, with Undo instead of a confirm (and a voice matched to the wrong person split off). Names get their real types (people, places, organisations, events, works) by
rules, without a model, and a decision model types the ones the rules can't. Find what is most like a passage, an
asset, a topic or an entity, in a Similar tab on every asset or by selecting text anywhere, and, with anytopdf, photos
by what they show. Pages and shots are described in words by the model provider's vision model or, off by default, by
Florence-2 or Moondream on your own server. A recording's sound can be cleaned up before it's transcribed (noise,
volume, echo, and silence left out, with the transcript's times kept), and transcripts cleaned up before analysis
(stutters, "um"s, spacing and, with a local model, punctuation put back, and grammar by a LanguageTool server you
run), entities found by spaCy instead of the rules, and the dates people say read as days on the calendar, each off
by default.
by what they show. Search filters moments by person, emotion or the sound heard in them (laughter, applause,
music), with a count for each. Pages and shots are described in words by the model provider's vision model or, off by
default, by Florence-2 or Moondream on your own server. A recording's sound can be cleaned up before it's transcribed
(noise, volume, echo, and silence left out, with the transcript's times kept), and transcripts cleaned up before
analysis (stutters, "um"s, spacing and, with a local model, punctuation put back, and grammar by a LanguageTool server
you run), entities found by spaCy instead of the rules, and the dates people say read as days on the calendar, each
off by default.
([Graph](docs/graph.md), [entity pages](docs/notes.md#entity-pages),
[similar content](docs/processing.md#similar-content), [graph history](docs/graph-history.md),
[topics](docs/topics.md), [video](docs/video.md), [NSFW classification](docs/nsfw.md),
Expand Down
5 changes: 5 additions & 0 deletions changelog.d/sound-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- **Search finds moments by the sounds in them.** Laughter, applause, music and the other sounds a speech provider
hears are a search filter like emotion: type `event:laughter` (any case; `music` is the BGM tag), pick one under
Sounds in the filters, or pass `event` to `GET /api/v1/search`, a saved search or the MCP `search` tool. Each hit
says its sound, and the facets count them (`events`), leaving plain speech out ([API](docs/api.md#search),
[search](docs/processing.md#search)).
15 changes: 8 additions & 7 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1252,11 +1252,12 @@ GET /api/v1/mentions
supplementary transcripts, captions, translations and indexes, or in what a model says its shots and pages show) in the
namespaces you can read, best first: every word
(English stemming), "quoted phrases" as written, `OR` between alternatives, `NOT` or `-` before a word or phrase to leave out the lines
with it; `ns`, `speaker`, `emotion` and `recording`
narrow it, and `object` keeps to the assets a kind of object is seen in ([Objects](#objects));
with it; `ns`, `speaker`, `emotion`, `event` (a sound heard, such as laughter, applause or music, typed any way; music
is stored as `BGM`) and `recording` narrow it, and `object` keeps to the assets a kind of object is seen in
([Objects](#objects));
`limit`/`offset` page through it. `total` counts the moments ranked so far (`capped` when there may be more).
With `facets=true` it also counts all the matching moments, whatever the page, by namespace, person, emotion and
asset (`facets`: up to 50 values each, most first, and `moments`), and lists the kinds of object seen in the
With `facets=true` it also counts all the matching moments, whatever the page, by namespace, person, emotion, sound
(`events`; plain speech isn't one) and asset (`facets`: up to 50 values each, most first, and `moments`), and lists the kinds of object seen in the
assets they're in, with how many of those assets each is in (`objects`); past 20,000 moments the counts cover
20,000 of them (`partial`).

Expand All @@ -1269,8 +1270,8 @@ image's text: its `page`, from 0, and its `box` on it), `object` (a kind of obje
on a document's pages its `page`, with the `frame` and the `box` it's best seen in), `described` (what a shot or a page
shows, [Descriptions](#descriptions): the shot's time, or the `page`, and its `frame`) or `file` (a line of a
supplementary file: its `file`, `file_role` and `file_label`, and which `line`). A file's lines have no `t0` when the
file doesn't say when they are; the web app opens those in the resource's Files tab. A `speaker` or `emotion` filter
keeps to `said` hits.
file doesn't say when they are; the web app opens those in the resource's Files tab. A `speaker`, `emotion` or `event`
filter keeps to `said` hits, and each hit says its `emotion` and `event`.

`mode` says how the words are matched ([search by meaning](processing.md#search-by-meaning)): `keyword` (BM25),
`semantic` (passages an embedding model finds alike), `hybrid` (both, fused by rank) or `auto` (the default: hybrid
Expand Down Expand Up @@ -1782,7 +1783,7 @@ DELETE /api/v1/searches/{sid}
```

Saved searches: `POST` with a `name` (unique among yours), the search's `q`, and any of `namespace`, `speaker`,
`emotion` and `recording`, as `GET /search` takes them. They follow the rules of saved views (above): yours, or
`emotion`, `event` and `recording`, as `GET /search` takes them. They follow the rules of saved views (above): yours, or
`shared` with the namespace they search (editors there; a search of every namespace can't be shared); only their maker
renames or shares them (`PATCH` with `name` or `shared`); their maker, or an owner of the namespace for a shared one,
deletes them. `GET` lists yours, then the shared ones, with `speaker_name` and `recording_title` where you can read
Expand Down
2 changes: 1 addition & 1 deletion docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ For an app that speaks OpenAI's API rather than MCP, the namespace can also answ
| Tool | |
|---|---|
| `lens_help` | Lens itself rather than the archive: the sections of Lens's docs that match a question (or one page read whole), each with its link, and this server's own facts: version, addresses (web app, MCP, API), what is turned on and your access ([Questions about Lens itself](assistant.md#questions-about-lens-itself)) |
| `search` | moments where words appear (written, spoken or shown on screen), best first: the asset, person, time, the matching text (matches in **bold**) and a link to that moment. Words, `"phrases"`, `OR`; by namespace, person or asset |
| `search` | moments where words appear (written, spoken or shown on screen), best first: the asset, person, time, the matching text (matches in **bold**) and a link to that moment. Words, `"phrases"`, `OR`; by namespace, person, asset or sound heard (laughter, applause, music …) |
| `find_similar` | the assets (recordings, documents and images) most like a text, an asset, a topic or an entity: why each is alike (meaning, key words, topics, entities in common) and the moments that show it, with links |
| `find` | assets by any attribute: faces seen, people heard, entities, topics, kind, content type, dates (a day, month or year), tags, language, objects, emotions, length, collection and words in them; ids or names. Each says why it matched, with moments and links to cite ([Finding by any attribute](processing.md#finding-by-any-attribute)) |
| `classify` | which of some labels fits an asset or a text, how sure, and every label ranked: labels you give, or Lens's content types or the namespace's topics; the decision model answers, else the language model. Read only: it changes nothing ([Classifying](processing.md#classifying)) |
Expand Down
6 changes: 3 additions & 3 deletions docs/processing.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ about them come back as not found.
All words must appear, matched after English stemming ("exploit" also finds exploits and exploiting); "quoted phrases"
must appear as written; OR separates alternatives; NOT, or a `-` before a word or "phrase", leaves out the lines that
have it (`budget -draft` finds budget without draft or drafts, `budget NOT "first draft"`), and a typed AND changes
nothing. Filter by namespace, person, emotion or asset. The words are
nothing. Filter by namespace, person, emotion, sound heard (`event:laughter`, applause, music …) or asset. The words are
looked up in the index of `search.engine`: a SQLite FTS5 file by default, SurrealDB's own index, or OpenSearch
([configuration](configuration.md#word-search)). For archives that aren't in English set `search.stemming: none` and
run `lens reindex`. There is no prefix search: a trailing `*` (`expl*`) is ignored and the word is searched as it is;
Expand All @@ -399,8 +399,8 @@ A search is matched three ways (`mode` on `GET /search`, the Match switch in the
* **keyword**: only the words, as above.
* **semantic**: only by meaning.

A passage found by meaning is shown at its line that best fits (the person or emotion filtered on, else the one with
most of the query's words), marked Related, with how alike it is (`similarity`, cosine). One that holds a keyword hit
A passage found by meaning is shown at its line that best fits (the person, emotion or sound filtered on, else the one
with most of the query's words), marked Related, with how alike it is (`similarity`, cosine). One that holds a keyword hit
adds to that hit's rank instead of showing twice. Only passages at least `embeddings.min_similarity` alike count, and
none much further than the closest one, so an unrelated query finds nothing rather than whatever is least unlike it.
Facets count the moments found by meaning too. The assistant's search tool and chat's retrieval use the same passages,
Expand Down
1 change: 1 addition & 0 deletions docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ request, to another check that proves the same thing; loosening a requirement or
| Names get their real types (people, places, organisations, events, works) by rules, without a model; older Terms are typed on upgrade | [test_name_types.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/domain/test_name_types.py) `test_names_get_the_type_their_words_give`, `test_analysis_types_people_places_and_events`, `test_the_upgrade_types_the_old_terms_and_keeps_what_people_chose` |
| A decision model (Jev or Laya) types the names the rules leave as Terms, in one call, keeping only types it is sure of; Settings › Analysis can leave them Terms | [test_name_types.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/domain/test_name_types.py) `test_the_decision_model_types_what_the_rules_cant`, `test_a_type_the_model_isnt_sure_of_stays_a_term`, `test_by_default_the_language_model_isnt_asked`, `test_with_typing_rules_nothing_is_asked` |
| What is most like a passage, an asset, a topic or an entity | [test_similar.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_similar.py) `test_like_a_text_by_its_key_words`, `test_like_a_resource_by_words_topics_and_entities`, `test_like_a_topic_and_an_entity`; [similar.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/similar.test.tsx) |
| Search finds and filters moments by the sound heard in them (laughter, applause, music), typed any way, and counts each | [test_search.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_search.py) `test_sound_events_filter_and_count`; [test_semantic.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_semantic.py) `test_search_by_meaning_keeps_to_a_sound`; [search.test.ts](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/search.test.ts) `reads sound events typed any way` |
| With anytopdf, photos found by what they show | [test_photos.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_photos.py) `test_photos_found_by_what_they_show`, `test_off_by_default` |
| Pages and shots described by the provider's vision model or, off by default, by Florence-2 on this server, fetched pinned and checked when picked | [test_florence.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_florence.py) `test_florence_describes_pictures_without_a_model_provider`, `test_florence_is_fetched_pinned_and_checked`, `test_admins_and_agents_choose_what_describes_pictures`, `test_a_picture_is_read_as_the_model_was_trained` |
| Or by Moondream on this server, off by default, fetched at a pinned revision only where the chosen model can run, and saying why where it can't | [test_moondream.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_moondream.py) `test_moondream_describes_pictures_without_a_model_provider`, `test_where_each_moondream_can_run`, `test_on_a_cpu_moondream_3_is_never_fetched_and_the_step_says_why`, `test_moondream_is_fetched_pinned`, `test_admins_and_agents_choose_moondream` |
Expand Down
12 changes: 11 additions & 1 deletion fastapi_backend/app/api/mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -462,11 +462,19 @@ def _recording(ctx: Context, rid: int) -> dict[str, Any]:
Arg("namespace", "string", "only this namespace (see list_namespaces)"),
Arg("speaker_id", "integer", "only lines from this person (ids from list_speakers or get_recording)"),
Arg("recording_id", "integer", "only within this asset"),
Arg("event", "string", "only lines with this sound: laughter, applause, music, cough …", max_length=40),
Arg("limit", "integer", "how many moments", default=10, minimum=1, maximum=50),
Arg("offset", "integer", "skip this many (for the next page)", default=0, minimum=0, maximum=10000),
)
def search(
ctx: Context, query: str, namespace: str | None, speaker_id: int | None, recording_id: int | None, limit: int, offset: int
ctx: Context,
query: str,
namespace: str | None,
speaker_id: int | None,
recording_id: int | None,
event: str | None,
limit: int,
offset: int,
) -> dict[str, Any]:
if recording_id is not None:
_recording(ctx, recording_id)
Expand All @@ -479,6 +487,7 @@ def search(
ns=namespace,
speaker=speaker_id,
emotion=None,
event=event,
recording=recording_id,
object=None,
limit=limit,
Expand All @@ -497,6 +506,7 @@ def search(
"recorded_at": h.recorded_at,
"speaker": h.speaker,
"speaker_id": h.speaker_id,
"event": h.event,
"found": h.source,
"at": store.tc(h.t0) if h.t0 is not None else None,
"seconds": _seconds(h.t0),
Expand Down
8 changes: 5 additions & 3 deletions fastapi_backend/app/api/v1/routes/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,14 @@ def search_transcripts(
ns: str | None = None,
speaker: int | None = None,
emotion: str | None = None,
event: str | None = Query(None, max_length=40, description="only lines with this sound (laughter, applause, music …), ignoring case"),
recording: int | None = None,
object: str | None = Query(None, max_length=60, description="only assets this kind of object is seen in (person, car …)"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
facets: bool = Query(
False,
description="also count all the matching moments by namespace, person, emotion and asset, and list their kinds of object",
description="also count all the matching moments by namespace, person, emotion, sound and asset, and list their kinds of object",
),
mode: Literal["auto", "keyword", "semantic", "hybrid"] = Query(
"auto",
Expand All @@ -46,8 +47,8 @@ def search_transcripts(
"""Moments where the words appear (said in audio or video, shown on screen in a video, written in a document or in
a resource's supplementary transcripts, captions, translations and indexes, or the kinds of object seen in videos,
documents and images),
best first, in the namespaces you can read and the collections you were given a role on. A person or emotion
filter keeps to their lines. By meaning, a moment is a passage about what was asked, though its words may differ."""
best first, in the namespaces you can read and the collections you were given a role on. A person, emotion or
sound filter keeps to their lines. By meaning, a moment is a passage about what was asked, though its words may differ."""
if ns:
acl.scope(ns) # 404 unless they see some of it
also = acl.partial_recordings()
Expand All @@ -69,6 +70,7 @@ def search_transcripts(
described=True,
cfg=cfg,
mode=mode,
event=event,
)
blur = nsfw.blurred(db, cfg, [h.get("recording_id") for h in res.get("hits") or []])
for h in res.get("hits") or []:
Expand Down
4 changes: 2 additions & 2 deletions fastapi_backend/app/api/v1/routes/searches.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,14 @@ def list_searches(user: CurrentUser, acl: Acl, db: Db) -> list[SavedSearch]:

@router.post("")
def create_search(body: SearchCreate, user: Writer, acl: Acl, db: Db) -> SavedSearch:
"""Save a search (its words, namespace, person, emotion, asset and object) under a name unique among yours. Sharing it
"""Save a search (its words, namespace, person, emotion, sound, asset and object) under a name unique among yours. Sharing it
with its namespace needs editor access there."""
sid = acl.namespace(body.namespace) if body.namespace else None
if body.recording is not None:
acl.recording(body.recording)
if body.shared:
check_share(acl, sid, "saved search")
state = body.model_dump(include={"q", "speaker", "emotion", "recording", "object"})
state = body.model_dump(include={"q", "speaker", "emotion", "event", "recording", "object"})
with domain_errors():
vid = views.create(db, user.id, body.name, sid, state, body.shared, kind="search")
if body.shared:
Expand Down
Loading
Loading
0