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
12 changes: 7 additions & 5 deletions backend/app/enrichment/describe.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from app.providers import build_provider
from app.providers.base import ProviderError, ProviderUnavailable
from app.services import assets as asset_service
from app.services import tags as tag_service
from app.usage import events as usage_events

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -55,7 +56,7 @@
)


def _prompt(asset: Asset, material: source.SourceMaterial) -> str:
def _prompt(asset: Asset, material: source.SourceMaterial, tag_names: list[str]) -> str:
parts = [f"Filename: {asset.original_name or asset.name}"]
if asset.summary:
# Context, not something to restate — saying so is what stops the model
Expand All @@ -64,6 +65,8 @@ def _prompt(asset: Asset, material: source.SourceMaterial) -> str:
f"A summary already exists; do not repeat it, describe what is in the "
f"item instead: {asset.summary}"
)
if tag_names:
parts.append("Tags already applied to this item: " + ", ".join(tag_names))

if material.kind == source.FROM_TRANSCRIPT:
header = "Transcript of the recording"
Expand Down Expand Up @@ -109,8 +112,9 @@ def run(session: Session, asset: Asset, progress: Progress) -> str:
)

progress("Describing", 30, "")
tag_names = [t.name for t in tag_service.tags_for(session, asset.id)]
completion = provider.complete(
_prompt(asset, material),
_prompt(asset, material, tag_names),
system=SYSTEM_PROMPT,
images=material.images,
)
Expand All @@ -129,9 +133,7 @@ def run(session: Session, asset: Asset, progress: Progress) -> str:
# land a description afterwards.
progress("Saving", 90, "")

written = asset_service.apply_ai_metadata(session, asset, {"description": text})
if not written:
return "Left alone — you wrote this description yourself"
asset_service.apply_ai_metadata(session, asset, {"description": text})

saw = " and a frame" if material.images and material.kind == source.FROM_TRANSCRIPT else ""
logger.info(
Expand Down
24 changes: 13 additions & 11 deletions backend/app/enrichment/summarize.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""The summarize job: turn what an asset contains into a paragraph about it.

The first job in M6 to produce something a person reads, and the first AI write path in
the app — so it is also the first caller of `apply_ai_metadata`, which is what keeps a
re-run from replacing a summary somebody wrote by hand (FR 8.1.3).
the app — so it is also the first caller of `apply_ai_metadata`. Pressing the button is
an explicit request for a fresh summary, so a re-run always replaces whatever is there,
including a summary somebody typed by hand — `apply_ai_metadata` still records that the
result came from AI, but nothing stops it from landing.

What it reads is not this module's decision; `enrichment/source.py` makes it once for
every job that will need it. For a transcribed video that is the transcript.
Expand All @@ -20,6 +22,7 @@
from app.providers import build_provider
from app.providers.base import ProviderError, ProviderUnavailable
from app.services import assets as asset_service
from app.services import tags as tag_service
from app.usage import events as usage_events

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -49,11 +52,13 @@
)


def _prompt(asset: Asset, material: source.SourceMaterial) -> str:
def _prompt(asset: Asset, material: source.SourceMaterial, tag_names: list[str]) -> str:
parts = [f"Filename: {asset.original_name or asset.name}"]
if asset.description:
# A description the user wrote is context, not something to restate.
parts.append(f"The owner's own note about it: {asset.description}")
if tag_names:
parts.append("Tags already applied to this item: " + ", ".join(tag_names))

if material.kind == source.FROM_TRANSCRIPT:
header = "Transcript of the recording"
Expand Down Expand Up @@ -96,15 +101,16 @@ def run(session: Session, asset: Asset, progress: Progress) -> str:
)
progress("Summarising", 30, detail)

tag_names = [t.name for t in tag_service.tags_for(session, asset.id)]
completion = provider.complete(
_prompt(asset, material),
_prompt(asset, material, tag_names),
system=SYSTEM_PROMPT,
images=material.images,
)

# Recorded before this job decides what to do with the answer, so usage does not
# depend on the outcome: a reply that gets trimmed, or that provenance then refuses
# to write, cost the same tokens as one that lands.
# depend on the outcome: a reply that gets trimmed still cost the same tokens as one
# that does not.
#
# Not a complete guarantee, and the gap is worth knowing: a reply the *provider's*
# parser rejects — an empty completion, unusable JSON — raises before there is a
Expand All @@ -123,11 +129,7 @@ def run(session: Session, asset: Asset, progress: Progress) -> str:
# not still land a summary afterwards.
progress("Saving", 90, "")

written = asset_service.apply_ai_metadata(session, asset, {"summary": text})
if not written:
# Not an error. The user wrote their own summary, which outranks this one — but
# saying so beats a job that reports success and changed nothing.
return "Left alone — you wrote this summary yourself"
asset_service.apply_ai_metadata(session, asset, {"summary": text})

logger.info(
"Summarised asset %s from %s (%d in / %d out tokens)",
Expand Down
36 changes: 13 additions & 23 deletions backend/app/services/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,10 +265,11 @@ def delete_asset(session: Session, storage: LocalStorage, asset: Asset) -> None:
def apply_metadata(session: Session, asset: Asset, changes: dict) -> Asset:
"""Apply a user's manual edits, recording that a human made them.

The provenance write is what FR 8.1.3 rests on: a later AI enrichment run reads it
to know which fields a person has touched, and must not overwrite those without
asking. Recording it here — at the only place manual edits happen — is what keeps
that guarantee from depending on every future caller remembering it.
Stamping "human" here still matters for `name`: a suggested title is only ever
applied through this path (`services/suggestions.py::accept`), and there is no
direct AI write path that could replace it afterwards. For `description` and
`summary` the stamp is now informational only — `apply_ai_metadata` no longer reads
it before overwriting either field.
"""
if not changes:
return asset
Expand All @@ -293,19 +294,15 @@ def apply_metadata(session: Session, asset: Asset, changes: dict) -> Asset:


def apply_ai_metadata(session: Session, asset: Asset, changes: dict) -> list[str]:
"""Write fields an enrichment job produced, without overwriting a person's work.
"""Write fields an enrichment job produced.

The counterpart to `apply_metadata`, and the first thing to actually *read*
`field_provenance` — FR 8.1.3 requires that a later AI run never silently replaces
something someone typed, and until now the column was written and consulted by
nothing.
The counterpart to `apply_metadata`. Pressing a "Generate"/"Summarize"/"Describe"
control is a deliberate, explicit request for a fresh answer, so it always lands —
including over a value a person typed by hand. `field_provenance` is still stamped
"ai" afterwards, so the record of who wrote a field stays accurate even though
nothing here gates on it any more.

A field with no provenance entry has never been touched by a person, so it is free
to write: absence is the signal, and no third provenance value is needed for it. A
field marked "human" is skipped and named in the return value, so the caller can say
what it left alone rather than reporting a clean run that quietly did less.

Returns the fields actually written.
Returns the fields written (always every key in `changes`, once any are given).
"""
if not changes:
return []
Expand All @@ -315,16 +312,9 @@ def apply_ai_metadata(session: Session, asset: Asset, changes: dict) -> list[str
except ValueError:
provenance = {}

written: list[str] = []
for field_name, value in changes.items():
if provenance.get(field_name) == "human":
continue
setattr(asset, field_name, value)
provenance[field_name] = "ai"
written.append(field_name)

if not written:
return []

asset.field_provenance = json.dumps(provenance, sort_keys=True)
asset.metadata_modified_date = utcnow()
Expand All @@ -333,7 +323,7 @@ def apply_ai_metadata(session: Session, asset: Asset, changes: dict) -> list[str
session.commit()
session.refresh(asset)
_reindex(session, asset)
return written
return list(changes.keys())


# ─── serialisation ───────────────────────────────────────────────────────────
Expand Down
3 changes: 2 additions & 1 deletion backend/tests/test_assets_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,8 @@ def test_patch_updates_metadata_and_records_human_provenance(library, session):
assert response.status_code == 200
assert response.json()["data"]["name"] == "Ocean at sunset"

# FR 8.1.3: a later AI run reads this to know what a person wrote.
# FR 8.1.3 bookkeeping: still recorded, though only `name` has anything left that
# reads it (a suggested title never overwrites one a person already chose).
asset = session.get(Asset, created["id"])
import json

Expand Down
25 changes: 20 additions & 5 deletions backend/tests/test_describe.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@
from app.providers import _upstream
from app.providers.base import ProviderError
from app.services import assets as asset_service
from app.services import tags as tag_service

from tests.test_summarize import _upload_image, add_transcript, configure_provider
from tests.test_summarize import TEST_USER, _upload_image, add_transcript, configure_provider

DESCRIPTION = "James Giordano speaking to camera in a panelled studio, discussing DARPA."

Expand Down Expand Up @@ -115,6 +116,18 @@ def test_an_existing_summary_is_context_not_material_to_repeat(library, session,
assert "do not repeat it" in prompt


def test_the_assets_own_tags_are_given_as_context(library, session, upstream):
created = _upload_image(library)
asset = session.get(Asset, created["id"])
tag = tag_service.get_or_create(session, TEST_USER, "DARPA")
tag_service.attach(session, asset.id, tag.id)
configure_provider(session)

describe.run(session, asset, lambda *a, **k: None)

assert "DARPA" in sent_prompt(upstream)


# ─── the ordinary paths ──────────────────────────────────────────────────────


Expand All @@ -139,17 +152,19 @@ def test_the_description_lands_on_the_asset(library, session, upstream):
assert json.loads(asset.field_provenance)["description"] == "ai"


def test_it_refuses_to_overwrite_a_description_someone_wrote(library, session, upstream):
def test_a_description_someone_wrote_is_replaced_when_asked(library, session, upstream):
"""Pressing Describe is an explicit request for a fresh answer, so it always lands
— even over a description a person typed themselves."""
created = _upload_image(library)
asset = session.get(Asset, created["id"])
asset_service.apply_metadata(session, asset, {"description": "Mine, thanks."})
configure_provider(session)

detail = describe.run(session, asset, lambda *a, **k: None)
describe.run(session, asset, lambda *a, **k: None)

session.refresh(asset)
assert asset.description == "Mine, thanks."
assert "you wrote this" in detail.lower()
assert asset.description == DESCRIPTION
assert json.loads(asset.field_provenance)["description"] == "ai"


def test_an_over_long_reply_is_trimmed(library, session, upstream):
Expand Down
49 changes: 27 additions & 22 deletions backend/tests/test_summarize.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

Two behaviours matter more than the rest and have most of the cases below: what the job
chooses to read (a transcribed video is summarised from its transcript, never from one
frame of it), and what it refuses to overwrite (a summary somebody typed).
frame of it), and that pressing the button always lands a fresh answer — even over a
summary somebody typed by hand.
"""

import json
Expand All @@ -23,6 +24,7 @@
from app.providers import _upstream
from app.providers.base import ProviderError
from app.services import assets as asset_service
from app.services import tags as tag_service

FIXTURES = Path(__file__).parent / "fixtures"
TEST_USER = "user-under-test"
Expand Down Expand Up @@ -225,6 +227,18 @@ def test_the_owners_own_note_is_given_as_context(library, session, upstream):
assert "Shot on the roof in Lisbon." in sent_prompt(upstream)


def test_the_assets_own_tags_are_given_as_context(library, session, upstream):
created = _upload_image(library)
asset = session.get(Asset, created["id"])
tag = tag_service.get_or_create(session, TEST_USER, "DARPA")
tag_service.attach(session, asset.id, tag.id)
configure_provider(session)

summarize.run(session, asset, lambda *a, **k: None)

assert "DARPA" in sent_prompt(upstream)


# ─── what it writes ──────────────────────────────────────────────────────────


Expand All @@ -240,19 +254,19 @@ def test_the_summary_lands_on_the_asset(library, session, upstream):
assert json.loads(asset.field_provenance)["summary"] == "ai"


def test_it_refuses_to_overwrite_a_summary_someone_wrote(library, session, upstream):
"""FR 8.1.3. `field_provenance` was written from M1 and read by nothing until now."""
def test_a_summary_someone_wrote_is_replaced_when_asked(library, session, upstream):
"""Pressing Summarize is an explicit request for a fresh answer, so it always lands
— even over a summary a person typed themselves."""
created = _upload_image(library)
asset = session.get(Asset, created["id"])
asset_service.apply_metadata(session, asset, {"summary": "Mine, thanks."})
configure_provider(session)

detail = summarize.run(session, asset, lambda *a, **k: None)
summarize.run(session, asset, lambda *a, **k: None)

session.refresh(asset)
assert asset.summary == "Mine, thanks."
# Reported rather than silently doing nothing and claiming success.
assert "you wrote this" in detail.lower()
assert asset.summary == SUMMARY
assert json.loads(asset.field_provenance)["summary"] == "ai"


def test_a_previous_ai_summary_is_replaced(library, session, upstream):
Expand Down Expand Up @@ -302,8 +316,9 @@ def test_ai_metadata_writes_an_untouched_field(library, session):
assert json.loads(asset.field_provenance) == {"summary": "ai"}


def test_ai_metadata_skips_only_the_human_field(library, session):
"""A mixed write should land the parts it is allowed to, not abort wholesale."""
def test_ai_metadata_overwrites_a_field_marked_human(library, session):
"""A field someone edited by hand is still fair game for the next AI write —
pressing Generate again is what asked for this."""
created = _upload_image(library)
asset = session.get(Asset, created["id"])
asset_service.apply_metadata(session, asset, {"description": "Mine."})
Expand All @@ -312,21 +327,11 @@ def test_ai_metadata_skips_only_the_human_field(library, session):
session, asset, {"description": "theirs", "summary": "auto"}
)

assert written == ["summary"]
assert written == ["description", "summary"]
session.refresh(asset)
assert asset.description == "Mine."
assert asset.description == "theirs"
assert asset.summary == "auto"


def test_ai_metadata_with_nothing_allowed_writes_nothing(library, session):
created = _upload_image(library)
asset = session.get(Asset, created["id"])
asset_service.apply_metadata(session, asset, {"summary": "Mine."})
before = asset.metadata_modified_date

assert asset_service.apply_ai_metadata(session, asset, {"summary": "auto"}) == []
session.refresh(asset)
assert asset.metadata_modified_date == before
assert json.loads(asset.field_provenance)["description"] == "ai"


# ─── the endpoint ────────────────────────────────────────────────────────────
Expand Down
16 changes: 0 additions & 16 deletions backend/tests/test_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,22 +142,6 @@ def test_a_provider_reporting_no_tokens_records_nothing(library, session, upstre
assert session.exec(select(UsageEvent)).all() == []


def test_usage_does_not_depend_on_what_happens_to_the_answer(library, session, upstream):
"""Provenance refuses the write, but the tokens were still spent. A total that only
counted runs whose output landed would understate what the library cost."""
from app.services import assets as asset_service

created = _upload_image(library)
asset = session.get(Asset, created["id"])
asset_service.apply_metadata(session, asset, {"summary": "Mine, thanks."})
configure_provider(session)

detail = summarize.run(session, asset, lambda *a, **k: None)

assert "you wrote this" in detail.lower()
assert len(session.exec(select(UsageEvent)).all()) == 1


def test_a_reply_the_provider_parser_rejects_is_not_recorded(library, session, upstream):
"""The one gap, asserted rather than left to be discovered. An empty completion
raises inside the client, before this job holds a Completion to record — so those
Expand Down
Loading
Loading