8000 feat(calendar): propose an event from a date said in an asset, sent only once approved by adeelahmad · Pull Request #688 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
8000
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ listed under [In progress](#in-progress), and what comes next under [Planned](#p
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. Words transcription wasn't sure of are underlined, with how sure, to check and correct in place. Translate a transcript into another language when you ask, with the estimate shown first: it's kept
beside the asset as a translation whose lines keep their times, found by search like an uploaded one.
A date said ("let's meet next Tuesday at 3") becomes a calendar event in one click,
prefilled from the line and added only once you approve it ([propose an event](docs/processing.md#propose-an-event)).
([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
7 changes: 7 additions & 0 deletions changelog.d/date-proposals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
- **A date said becomes a calendar event, once you approve it.** Next to each date said in an asset (its Entities tab,
under Dates said), **Propose event** makes an event for a Google Calendar connection an admin let add events,
prefilled from the line: the day said at a time said in it ("at 3" is 15:00) or all day, the line as its title, and
who it names (for an email, its sender and recipients), never emailed unless you ask. It waits in Approvals, and
nothing is added or sent until you approve it there, signed in; declining it does nothing. Agents propose through
`POST /api/v1/dates/propose` and the MCP tool `propose_event`, and can decline but never approve
([Propose an event](docs/processing.md#propose-an-event)).
3 changes: 2 additions & 1 deletion docs/assistant.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,8 @@ tools for them, only for the kinds connected, with nothing to turn on:
- **`add_event`** adds an event to a calendar of a Google Calendar connection an admin let add events: `title`,
`start` and `end` (a date alone is all day; a time without an offset is in the calendar's own time zone; it lasts an
hour, or a day, when `end` is left out), `where`, `description`, and `attendees`, who are emailed an invitation
unless `invite` is false.
unless `invite` is false. A date said in an asset can also be proposed as an event with one click, prefilled from
its line ([Propose an event](processing.md#propose-an-event)).
- **`write_file`** writes a text file (Markdown, CSV, notes, code) to a storage connection: a new file, or one it
replaces when `overwrite` is true. A file that turned up after it asked isn't replaced. Google Drive connected read
only (its default) can't be written to.
Expand Down
3 changes: 2 additions & 1 deletion docs/automations.md
Original file line number Diff line number Diff line change
Expand Up @@ -857,7 +857,8 @@ isn't the viewer's to see.
## Approvals

Built: everything that waits for your pick is an approval in one table (`app/domain/approvals.py`): the assistant's
proposals in a conversation, as before, and an automation's actions that ask first. When an action node's intent asks
proposals in a conversation, as before, an automation's actions that ask first, and events proposed from dates said
in an asset ([Propose an event](processing.md#propose-an-event)), which only you, signed in, approve. When an action node's intent asks
(its effect is `out`, or `lens` on anything but the event's own object), the engine hands it to
`approvals.ask(db, cfg, intent, event=None)`, which keeps:

Expand Down
1 change: 1 addition & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ For an app that speaks OpenAI's API rather than MCP, the namespace can also answ
| `list_entities` | people, organisations, products, places, events, works and terms mentioned, by name, type, namespace or asset |
| `get_entity` | one entity: its names, its page's link, how often and when it is mentioned, what it is mentioned with, who mentions it most, and the lines that mention it |
| `list_dates` | what was said about a day or a range: dates said in assets as days on the calendar ([Dates](processing.md#dates)), each with the words, the line and a link to that moment |
| `propose_event` | a date said, as a calendar event prefilled from its line (the day, a time said, the line as its title, who it names), waiting in Approvals for the person's yes; admins with a calendar connection that may add events ([Propose an event](processing.md#propose-an-event)) |
| `explore_graph` | the knowledge graph around an entity (`e12`) or a person (`s3`), one or two steps out |
| `find_path` | the shortest chain of links between two of them, with lines that show each link |
| `sparql` | a read-only SPARQL query (SELECT, ASK, CONSTRUCT or DESCRIBE) over one namespace's linked data ([RDF](rdf.md)): JSON results, or Turtle |
Expand Down
28 changes: 28 additions & 0 deletions docs/processing.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,34 @@ its line, linked to that moment.
- The rules read English. Nothing is sent anywhere and no model is called. Turning it on reads dates as assets are
analysed; Reprocess (Analyze) reads them for assets already there.

#### Propose an event

Built (`app/domain/event_proposals.py`). A date said can become an event in a Google Calendar connection an admin let
add events ([Mail, calendars and files](assistant.md#mail-calendars-and-files)), proposed with one click: **Propose
event** next to it under Dates said in an asset's Entities tab, `POST /api/v1/dates/propose` (`recording_id` and the
line's `segment`, as `GET /api/v1/dates` gives them), or the MCP tool `propose_event`. It waits in
[Approvals](automations.md#approvals) as an `add_event`, with all of the event shown, and nothing is added or sent until
you approve it there, signed in to Lens; declining it does nothing. An API key, an app, MCP and the assistant can
propose and decline, never approve. Proposing the same date again while it waits gives the same approval.

- **Prefilled from the line:** the day said, at a time of day said in the same line ("at 3", "3pm", "9:45 a.m.",
"15:30", "noon"; else all day), and lasting an hour. A time without am or pm from 1 to 6 is in the afternoon ("at 3"
is 15:00), from 7 to 11 in the morning, and it is in the calendar's own time zone. A weekend or a week is all of its
days; a month or a year is too long to be one event, so it needs a `start`. The title is the line itself, shortened;
who is invited are the addresses in the line and, for an email, its sender and recipients (not the calendar's own
account or yours), and they aren't emailed an invitation unless `invite` is true. The description says where it was
said, with a link to that moment.
- **Change any of it:** `title`, `start`, `end`, `where`, `description`, `attendees` (`[]` for nobody), `invite`,
`connection` and `calendar` replace what was guessed.
- **Who and when:** admins only (adding an event goes through an admin's connection), for an asset they can read, and
only when a calendar connection may add events: `GET /api/v1/dates` says so in `proposable`, and the button shows only
then. Nothing is proposed on its own: each proposal is asked for. No model is called; each proposal is in the audit
log (`calendar.propose`), and each event added (`calendar.add`).
- Refine later: a setting to propose dates on their own as assets are analysed (off by default); editing the event on
its approval card before approving (today: propose again with the fields changed, after declining); a time range or
a length said in the line ("from 3 to 5", "for half an hour"); speakers' own addresses as guests; calendars other than
< 10000 /td> Google Calendar (CalDAV); the assistant proposing from `find_dates` without a conversation (it uses `add_event`).

### Entity setup

Each namespace has an entity setup (Entities → Setup, `GET`/`PUT /api/v1/namespaces/{name}/entity-setup`): the types
Expand Down
1 change: 1 addition & 0 deletions docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ request, to another check that proves the same thing; loosening a requirement or
| Entities found by spaCy, fetched the first time it's picked, with the defined entities first and the rules until it's there | [test_spacy_entities.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/domain/test_spacy_entities.py) `test_defined_entities_come_first_then_spacy_s_names`, `test_the_analyze_step_and_workflows_use_it`, `test_without_spacy_the_rules_find_them`, `test_spacy_is_fetched_only_when_it_is_picked` |
| Dates said in an asset read as days ("next Tuesday", "last week"), off by default, with dates that read two ways left out | [test_dates.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_dates.py) `test_dates_are_read_as_days`, `test_what_could_be_two_dates_is_left_out`, `test_off_by_default_reads_none` |
| A transcript translated into another language only when someone asks, the estimate first: in batches of lines that keep their times and speaker, the transcript marked as data, every call in the activity ledger and within budgets, kept as a translation file like an uploaded one; through the web app's Files tab, the API, MCP and the assistant (asking first) | [test_translations.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_translations.py) `test_translating_writes_a_translation_file`, `test_the_transcript_reaches_the_model_as_data`, `test_lines_go_in_batches_and_keep_their_place`, `test_a_spent_budget_refuses`, `test_the_estimate_comes_first`, `test_what_stops_a_translation`, `test_agents_translate`, `test_the_assistant_asks_first`; [recording-translate.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/recording-translate.test.tsx) `is the first the browser reads that the transcript isn't in`, `translates into the browser's language with Enter, after showing the estimate` |
| A date said in an asset is proposed as a calendar event in one click, prefilled from its line (the day, a time said, the line as its title, who it names), and added only once the person approves it, signed in; declined, nothing is added; offered only to admins with a calendar connection that may add events, and agents propose over MCP but can't approve | [test_event_proposals.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_event_proposals.py) `test_a_date_said_is_proposed_and_added_only_once_approved`, `test_a_declined_proposal_adds_nothing`, `test_offered_only_to_admins_with_a_calendar_that_adds_events`, `test_agents_propose_over_mcp_but_cannot_approve`, `test_the_time_of_day_is_read_from_the_line`; [said-dates.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/said-dates.test.tsx) `proposes an event from a date in one click, and says it waits in Approvals`, `offers no Propose event without a calendar that adds events, and shows nothing without dates` |

## Learn from it

Expand Down
69 changes: 68 additions & 1 deletion fastapi_backend/app/api/mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
descriptions,
enhance,
event_mutes,
event_proposals,
event_reactions,
events,
extensions,
Expand Down Expand Up @@ -1297,7 +1298,8 @@ def _scope(namespace: str | None) -> str:
'Tuesday after, "last week" the seven days before that Monday, "March 2024" all of March. Give start and end '
"(YYYY-MM-DD, or YYYY-MM or YYYY) for what falls between them, the earliest first, each with the words said, the "
"line and a url to that moment. relative: worked out from the asset's own date. Only assets analysed while Settings "
"→ Analysis → Read dates was on have them (reading says whether it is).",
"→ Analysis → Read dates was on have them (reading says whether it is). proposable: propose_event can turn one "
"into a calendar event that waits for the person's yes.",
Arg("start", "string", "from this day on: YYYY-MM-DD, YYYY-MM or YYYY", max_length=10),
Arg("end", "string", "up to this day: YYYY-MM-DD, YYYY-MM or YYYY", max_length=10),
Arg("namespace", "string", "only this namespace"),
Expand Down Expand Up @@ -1331,6 +1333,7 @@ def list_dates(
)
out: dict[str, Any] = {
"reading": res.reading,
"proposable": res.proposable,
"total": res.total,
"dates": [
{
Expand All @@ -1346,6 +1349,7 @@ def list_dates(
"namespace": d.namespace,
"recorded_at": d.recorded_at,
"line": d.idx,
"segment": d.segment,
"at": d.time,
"speaker": d.speaker,
"text": _cut(d.text),
Expand All @@ -1361,6 +1365,69 @@ def list_dates(
return out


@tool(
"propose_event",
"Propose a date said as an event",
"Propose a date said in an asset (from list_dates: its recording_id and line) as an event in a Google Calendar "
"connection that may add events, prefilled from the line: the day said at a time said in it (else all day), the "
"line as its title, and who it names (for an email, its sender and recipients). It waits in Approvals for the "
"person's yes; nothing is added or sent before, and you can't approve it. Give any field to use instead of what was "
"guessed. Admins only; needs a token with the write scope.",
Arg("recording_id", "integer", "the asset"),
Arg("segment", "integer", "the line's id, as list_dates gives it"),
Arg("line", "integer", "or the line's place in the asset's text, as list_dates gives it"),
Arg("index", "integer", "which of the line's dates, the earliest first", default=0, minimum=0, maximum=50),
Arg("title", "string", "else the line, shortened", max_length=300),
Arg("start", "string", "ISO 8601; else the day said, at the time said", max_length=40),
Arg("end", "string", "ISO 8601; else an hour after start, or a day", max_length=40),
Arg("where", "string", "where it is", max_length=500),
Arg("attendees", "string[]", "addresses ([] for nobody); else those it names"),
Arg("invite", "boolean", "email them an invitation when it's added (off by default)"),
Arg("connection", "string", "the calendar connection, by id or name", max_length=200),
Arg("calendar", "string", "one of its calendars, by id or name", max_length=300),
writes=True,
admin=True,
)
def propose_event(
ctx: Context,
recording_id: int,
segment: int | None,
line: int | None,
index: int,
title: str | None,
start: str | None,
end: str | None,
where: str | None,
attendees: list[str] | None,
invite: bool | None,
connection: str | None,
calendar: str | None,
) -> dict[str, Any]:
_writer(ctx, "propose events")
if not ctx.user.admin:
raise ToolError("only admins propose events: adding one goes through an admin's calendar connection")
try:
ctx.acl.recording(int(recording_id))
except HTTPException:
raise ToolError(f"there's no asset {recording_id} you can read") from None
if segment is None and line is not None:
seg = ctx.db.one("SELECT record::id(id) AS id FROM segment WHERE recording = $r AND idx = $i", r=int(recording_id), i=int(line))
segment = (seg or {}).get("id")
if segment is None:
raise ToolError("give the line: segment, or line (its place, as list_dates gives it)")
given = {"title": title, "start": start, "end": end, "where": where, "attendees": attendees, "invite": invite}
given.update(connection=connection, calendar=calendar)
try:
out = event_proposals.propose(
ctx.db, ctx.cfg, ctx.user.as_audit(), int(recording_id), int(segment), int(index or 0), web=ctx.web,
**{k: v for k, v in given.items() if v is not None},
) # fmt: skip
except (LookupError, ValueError) as e:
raise ToolError(str(e)) from None
out.pop("event", None)
return {**out, "url": f"{ctx.web}/approvals?id={out['approval']}"}


@tool(
"explore_graph",
"Explore the knowledge graph",
Expand Down
27 changes: 23 additions & 4 deletions fastapi_backend/app/api/v1/routes/dates.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@

from fastapi import APIRouter, HTTPException, Query

from app.api.deps import Acl, Cfg, CurrentUser, Db
from app.domain import dates
from app.schemas.dates import DateList
from app.api.deps import Acl, AdminWriter, Cfg, CurrentUser, Db
from app.domain import approvals, dates, event_proposals
from app.email import app_url
from app.schemas.dates import DateList, EventProposal, EventProposed

router = APIRouter(tags=["dates"])

Expand Down Expand Up @@ -34,4 +35,22 @@ def list_dates(
raise HTTPException(400, str(e)) from None
spaces, within = acl.scope(ns)
out = dates.said(db, spaces, within, a, b, recording, q.strip() or None, max(1, min(limit, 200)), max(0, offset))
return DateList.model_validate({**out, "reading": bool((cfg.get("analysis") or {}).get("dates"))})
reading = bool((cfg.get("analysis") or {}).get("dates"))
return DateList.model_validate({**out, "reading": reading, "proposable": event_proposals.offered(db, user.admin)})


@router.post("/dates/propose")
def propose_event(body: EventProposal, acl: Acl, user: AdminWriter, db: Db, cfg: Cfg) -> EventProposed:
"""Propose a date said in an asset as a calendar event: an event for a Google Calendar connection that may add
events, prefilled from the line (the day said, a time said in it, the line as its title, who it names), waiting in
Approvals for your yes. Nothing is added or sent until you approve it there; declining it does nothing. Proposing
the same date again while it waits gives the same approval."""
acl.recording(body.recording_id)
given = body.model_dump(exclude={"recording_id", "segment", "index"}, exclude_unset=True)
try:
out = event_proposals.propose(db, cfg, user.as_audit(), body.recording_id, body.segment, body.index, web=app_url(cfg, db), **given)
except LookupError as e:
raise HTTPException(404, str(e)) from None
except ValueError as e:
raise HTTPException(400, str(e)) from None
return EventProposed.model_validate({**out, "url": approvals.link(cfg, db, out["approval"])})
Loading
Loading
0