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
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,40 @@ Keep a Changelog format.

## [Unreleased]

### 🔍 One box for finding anything, and doing anything

`Ctrl`/`⌘` + `K` opens a command palette from anywhere in the app — including
from inside a text field, since half of wanting it is being mid-sentence. It
replaces the header search box, which searched less and could do nothing.

**Search now sees the half of the library it was blind to.** A recording's
summary, its action items and its translations are all LLM output living in one
column that nothing indexed, so the words a user is most likely to remember
reading were the words search could not find. A new FTS5 index covers them, and
`/api/search` answers from five places at once — transcripts, LLM output, file
names and aliases, tags and folders. A recording that matches in several places
is still one result, labelled with every place it was found in, and ranked by
the strongest of them.

**The palette is also the app's command list.** Typing `/` filters commands —
go to a tab, ask your library, pick files, export a backup, open the console,
clear filters. `@` searches recordings, `#` searches folders and tags, and
anything else searches all of it at once. Commands, folders and tags are matched
in the browser and appear as you type; recordings come from the search endpoint,
debounced. The prefixes are the ones the terminal UI already uses, and the
matching is the same algorithm, ported.

Smaller things that came with it:

- **A recording renamed with an alias can now be found by that name.** Search
looked only at the original file name — the one name the user had chosen was
the one it ignored.
- **A search snippet is escaped before it is displayed.** Snippets carry
`<mark>` around the match and are otherwise raw transcript text, which the old
dropdown assigned straight to `innerHTML`.
- **`offset` walks the whole result list.** It used to be applied per source and
then merged, so paging skipped and repeated rows.

### 🧹 A structural pass over the whole codebase

A review of the repository against clean-architecture layering and clean-code
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ AmicoScript keeps everything local.
OpenRouter and other hosted providers are supported too, behind an explicit opt-in
- 🗣️ Speaker diarization (who said what)
- 🌍 Real-time translation to English
- 🔍 Global search across transcripts
- 🔍 Command palette (`Ctrl`/`⌘` + `K`) — one box that searches transcripts,
LLM summaries, names, tags and folders at once, and runs any command in the
app. A recording appears once, labelled with where it matched
- 💬 Ask your library — a question answered from every transcript at once, with
citations that open the recording at the second it was said. Works on keyword
search out of the box; name an embedding model and it searches by meaning
Expand Down
111 changes: 3 additions & 108 deletions backend/api/routes/folders_tags.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Folder, tag, and search endpoints."""
"""Folder and tag endpoints."""

import os
import threading
Expand All @@ -12,10 +12,9 @@
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from llm_providers import refusal_reason
from models import Analysis, Folder, Recording, RecordingTag, Tag, Transcript
from search_query import build_fts_match
from settings import get_llm_settings
from sqlalchemy import func, text
from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select

router = APIRouter()
Expand Down Expand Up @@ -315,110 +314,6 @@ def remove_recording_tag(recording_id: str, tag_id: str, session: Session = Depe
return {"ok": True}


@router.get("/api/search")
def search_library(q: str = "", limit: int = 20, offset: int = 0, session: Session = Depends(get_session)) -> list:
if not q.strip():
return []



safe_limit = max(1, min(limit, 100))

# Escape LIKE wildcards so % and _ in the query are treated literally
q_like = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"

# Never hand raw user input to MATCH — see backend/search_query.py.
fts_expr = build_fts_match(q)

try:
fts_rows = []
if fts_expr:
fts_rows = session.exec(
text(
"""
SELECT t.recording_id,
snippet(transcript_fts, 0, '<mark>', '</mark>', '…', 20) AS snippet
FROM transcript_fts
JOIN transcript t ON transcript_fts.rowid = t.rowid
WHERE transcript_fts MATCH :q
ORDER BY rank
LIMIT :lim OFFSET :off
"""
),
params={"q": fts_expr, "lim": safe_limit, "off": offset},
).all()

meta_rows = session.exec(
text(
"""
SELECT DISTINCT r.id as recording_id,
CASE
WHEN f.name LIKE :ql ESCAPE '\\' THEN 'Folder: ' || f.name
WHEN t.name LIKE :ql ESCAPE '\\' THEN 'Tag: ' || t.name
ELSE 'Title: ' || r.filename
END as snippet
FROM recording r
LEFT JOIN folder f ON r.folder_id = f.id
LEFT JOIN recordingtag rt ON r.id = rt.recording_id
LEFT JOIN tag t ON rt.tag_id = t.id
WHERE r.filename LIKE :ql ESCAPE '\\'
OR f.name LIKE :ql ESCAPE '\\'
OR t.name LIKE :ql ESCAPE '\\'
ORDER BY r.filename
LIMIT :lim OFFSET :off
"""
),
params={"ql": q_like, "lim": safe_limit, "off": offset},
).all()

fts_ids = {r.recording_id: r.snippet for r in fts_rows}
ordered = list(fts_rows)
for r in meta_rows:
if r.recording_id not in fts_ids:
ordered.append(r)
rows = ordered[:safe_limit]
except OperationalError:
rows = session.exec(
text(
"""
SELECT DISTINCT r.id AS recording_id,
CASE
WHEN f.name LIKE :ql ESCAPE '\\' THEN 'Folder: ' || f.name
WHEN t.name LIKE :ql ESCAPE '\\' THEN 'Tag: ' || t.name
WHEN r.filename LIKE :ql ESCAPE '\\' THEN 'Title: ' || r.filename
ELSE COALESCE(substr(tr.full_text, 1, 100), 'Metadata match')
END AS snippet
FROM recording r
LEFT JOIN transcript tr ON r.id = tr.recording_id
LEFT JOIN folder f ON r.folder_id = f.id
LEFT JOIN recordingtag rt ON r.id = rt.recording_id
LEFT JOIN tag t ON rt.tag_id = t.id
WHERE r.filename LIKE :ql ESCAPE '\\'
OR tr.full_text LIKE :ql ESCAPE '\\'
OR f.name LIKE :ql ESCAPE '\\'
OR t.name LIKE :ql ESCAPE '\\'
ORDER BY r.filename
LIMIT :lim OFFSET :off
"""
),
params={"ql": q_like, "lim": safe_limit, "off": offset},
).all()

results = []
for row in rows:
rec = session.get(Recording, row.recording_id)
if rec:
results.append(
{
"recording_id": row.recording_id,
"filename": rec.filename,
"duration": rec.duration,
"snippet": row.snippet,
}
)
return results


@router.post("/api/exit")
async def api_exit(request: Request, token: str = ""):
try:
Expand Down
18 changes: 18 additions & 0 deletions backend/api/routes/search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Global search — one query across transcripts, LLM output and metadata.

The ranking and the SQL live in core/search.py; this is only the door.
"""

from core.search import search_library
from db import get_session
from fastapi import APIRouter, Depends
from sqlmodel import Session

router = APIRouter()


@router.get("/api/search")
def search(
q: str = "", limit: int = 20, offset: int = 0, session: Session = Depends(get_session)
) -> list:
return search_library(session, q, limit=limit, offset=offset)
Loading
Loading