diff --git a/backend/alembic/versions/20261016_0600_add_asset_origin.py b/backend/alembic/versions/20261016_0600_add_asset_origin.py new file mode 100644 index 0000000..27067f8 --- /dev/null +++ b/backend/alembic/versions/20261016_0600_add_asset_origin.py @@ -0,0 +1,47 @@ +"""add asset origin: the GVC project and render a gvc_export came from + +GAM-3 (gvc's docs/plan-of-attack.md). Two nullable columns, set only on uploads that +Video Creator files back into the library with `source = "gvc_export"`: + +- `origin_project_id`: the GVC project. Indexed, because "this project's renders" is + the one question asked of it. +- `origin_reference_id`: the GVC render job that made the file. + +No foreign keys: both are ids in another application's database. + +Adding columns and an index is plain ALTER on SQLite, so the upgrade does not recreate +`asset`. The downgrade's drops do, hence the PRAGMA dance `a8e1c0f4d2b6` explains. + +Revision ID: 5b9e3d71c2a4 +Revises: a8e1c0f4d2b6 +Create Date: 2026-10-16 06:00:00.000000+00:00 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +revision: str = '5b9e3d71c2a4' +down_revision: Union[str, None] = 'a8e1c0f4d2b6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('asset', schema=None) as batch_op: + batch_op.add_column(sa.Column('origin_project_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + batch_op.add_column(sa.Column('origin_reference_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + batch_op.create_index('ix_asset_origin_project_id', ['origin_project_id'], unique=False) + + +def downgrade() -> None: + with op.get_context().autocommit_block(): + op.execute('PRAGMA foreign_keys=OFF') + with op.batch_alter_table('asset', schema=None) as batch_op: + batch_op.drop_index('ix_asset_origin_project_id') + batch_op.drop_column('origin_reference_id') + batch_op.drop_column('origin_project_id') + with op.get_context().autocommit_block(): + op.execute('PRAGMA foreign_keys=ON') diff --git a/backend/app/models/asset.py b/backend/app/models/asset.py index 98a1a40..1d7d3a1 100644 --- a/backend/app/models/asset.py +++ b/backend/app/models/asset.py @@ -151,6 +151,15 @@ class Asset(SQLModel, table=True): ai_seed: Optional[int] = None ai_generated_at: Optional[datetime] = None + # ─── origin in another suite app (GAM-3) ───────────────────────────────── + # Set only on `source == "gvc_export"`: the Video Creator project a render came from + # and the render job that made it. Ids in another app's database, so no foreign key + # can hold them, and neither is ever followed from here; they exist so GVC can find + # "this project's renders" in one indexed lookup, and so a person looking at the + # file can tell which project to open to change it. + origin_project_id: Optional[str] = Field(default=None, index=True) + origin_reference_id: Optional[str] = None + # ─── provenance of the metadata, not the file ─────────────────────────── # JSON, {"description": "ai"|"human"|"embedded", ...}. FR 8.1.3 requires that a later AI run # never silently overwrites something a person wrote; without recording who last diff --git a/backend/app/routers/assets.py b/backend/app/routers/assets.py index 71ff2c7..498339a 100644 --- a/backend/app/routers/assets.py +++ b/backend/app/routers/assets.py @@ -6,12 +6,12 @@ from datetime import datetime from typing import AsyncIterator, List, Optional -from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile, status from sqlmodel import Session, col, func, select from app.auth import CurrentUser from app.database import get_session -from app.ingest.filetypes import ASSET_TYPES +from app.ingest.filetypes import ASSET_TYPES, SOURCE_GVC, SOURCE_UPLOAD from app.jobs import enrichment as enrichment_jobs from app.jobs.registry import KINDS from app.models.asset import Asset @@ -64,6 +64,9 @@ def _owned(asset_id: str, user_id: str, session: Session) -> Asset: async def upload_assets( user: CurrentUser, files: List[UploadFile] = File(...), + source: Optional[str] = Form(default=None, max_length=50), + origin_project_id: Optional[str] = Form(default=None, max_length=64), + origin_reference_id: Optional[str] = Form(default=None, max_length=64), session: Session = Depends(get_session), storage: LocalStorage = Depends(get_storage), ) -> UploadResult: @@ -73,7 +76,25 @@ async def upload_assets( unsupported stores nineteen and reports the twentieth, rather than refusing all of them. A user who dragged in a folder should not have to find the offending file themselves. + + `source` and the `origin_*` ids are GAM-3: how Video Creator files a finished render + back into the library. `gvc_export` is the only source a client may claim. The + others (`ai_generated`, `clip`, `url`…) are statements GAM makes about files it + produced itself, and a client able to set them could pass an upload off as, say, a + generation the regenerate button then tries to rerun. """ + if source is not None and source not in (SOURCE_GVC, SOURCE_UPLOAD): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "invalid_source", "message": f"An upload cannot claim the source {source!r}"}, + ) + if (origin_project_id or origin_reference_id) and source != SOURCE_GVC: + # An origin only means something with the app that set it; on an ordinary + # upload it would be an id nobody can look up. + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "origin_without_source", "message": "An origin needs source=gvc_export"}, + ) created: list[AssetRead] = [] rejected: list[UploadRejection] = [] @@ -100,6 +121,9 @@ async def upload_assets( filename=filename, chunks=_stream(upload), content_type=upload.content_type, + source=source or SOURCE_UPLOAD, + origin_project_id=origin_project_id or None, + origin_reference_id=origin_reference_id or None, ) created.append(service.to_read_model(asset, storage)) except service.UnsupportedFile: diff --git a/backend/app/schemas_assets.py b/backend/app/schemas_assets.py index 48d7d55..9a1931f 100644 --- a/backend/app/schemas_assets.py +++ b/backend/app/schemas_assets.py @@ -78,6 +78,10 @@ class AssetRead(BaseModel): # M8. How this asset was made, when fal.ai made it; null for everything else. generation: Optional["AssetGenerationRead"] = None + # GAM-3. The GVC project and render a `gvc_export` came from; null otherwise. + origin_project_id: Optional[str] = None + origin_reference_id: Optional[str] = None + upload_date: datetime modified_date: datetime metadata_modified_date: datetime diff --git a/backend/app/services/assets.py b/backend/app/services/assets.py index 6d8c273..b474b04 100644 --- a/backend/app/services/assets.py +++ b/backend/app/services/assets.py @@ -73,6 +73,8 @@ async def ingest_upload( chunks: AsyncIterator[bytes], content_type: Optional[str] = None, source: str = SOURCE_UPLOAD, + origin_project_id: Optional[str] = None, + origin_reference_id: Optional[str] = None, ) -> Asset: """Store the bytes, then describe them. @@ -92,6 +94,8 @@ async def ingest_upload( asset_type=asset_type, source=source, content_type=content_type, + origin_project_id=origin_project_id, + origin_reference_id=origin_reference_id, ) @@ -150,6 +154,8 @@ def _catalogue( source: str, content_type: Optional[str], name: Optional[str] = None, + origin_project_id: Optional[str] = None, + origin_reference_id: Optional[str] = None, ) -> Asset: """Everything that happens once the bytes are safely stored.""" extension = extension_of(filename) @@ -164,6 +170,8 @@ def _catalogue( file_format=extension.lstrip(".") or None, size_bytes=stored.size_bytes, checksum_sha256=stored.sha256, + origin_project_id=origin_project_id, + origin_reference_id=origin_reference_id, ) session.add(asset) session.commit() @@ -899,6 +907,8 @@ def to_read_model( for t in (asset_tags or []) ], generation=generation_of(asset), + origin_project_id=asset.origin_project_id, + origin_reference_id=asset.origin_reference_id, upload_date=asset.upload_date, modified_date=asset.modified_date, metadata_modified_date=asset.metadata_modified_date, diff --git a/backend/tests/test_assets_api.py b/backend/tests/test_assets_api.py index 8894d64..fcc3281 100644 --- a/backend/tests/test_assets_api.py +++ b/backend/tests/test_assets_api.py @@ -25,6 +25,53 @@ def _upload(client, *names: str): # ─── upload ────────────────────────────────────────────────────────────────── +def _export(library, **fields): + data = (FIXTURES / "sample_video.mp4").read_bytes() + return library.post( + "/api/assets", + files=[("files", ("render.mp4", data, "video/mp4"))], + data=fields, + ) + + +def test_a_video_creator_render_is_filed_with_its_origin(library): + # GAM-3: GVC filing a finished render back into the library. + response = _export( + library, source="gvc_export", origin_project_id="proj-1", origin_reference_id="render-9" + ) + assert response.status_code == 201, response.text + asset = response.json()["created"][0] + assert asset["source"] == "gvc_export" + assert asset["origin_project_id"] == "proj-1" + assert asset["origin_reference_id"] == "render-9" + + # Read back the same, and found by the "Video Creator" filter. + listed = library.get("/api/assets", params={"source": "gvc_export"}).json()["data"] + assert [a["id"] for a in listed] == [asset["id"]] + assert library.get(f"/api/assets/{asset['id']}").json()["data"]["origin_project_id"] == "proj-1" + + +def test_an_ordinary_upload_has_no_origin(library): + asset = _upload(library, "sample_image.jpg").json()["created"][0] + assert asset["source"] == "local_upload" + assert asset["origin_project_id"] is None + assert asset["origin_reference_id"] is None + + +@pytest.mark.parametrize("source", ["ai_generated", "clip", "url", "sub_video", "nonsense"]) +def test_an_upload_cannot_claim_a_source_gam_reserves(library, source): + response = _export(library, source=source) + assert response.status_code == 400 + assert response.json()["detail"]["code"] == "invalid_source" + assert library.get("/api/assets").json()["total"] == 0 + + +def test_an_origin_needs_the_gvc_source(library): + response = _export(library, origin_project_id="proj-1") + assert response.status_code == 400 + assert response.json()["detail"]["code"] == "origin_without_source" + + def test_upload_stores_and_describes_an_image(library): response = _upload(library, "sample_image.jpg") assert response.status_code == 201 diff --git a/docs/plan-of-attack.md b/docs/plan-of-attack.md index 89b1683..fe3fa6e 100644 --- a/docs/plan-of-attack.md +++ b/docs/plan-of-attack.md @@ -363,7 +363,9 @@ from a decision, which is the distinction PR bodies do not preserve. Specified, with its decisions, operating notes and known limitations, in [`url-import.md`](url-import.md). `SOURCE_URL` now has its writer, and since M8 so does `SOURCE_AI` — `FilterBar`'s "AI generated" filter finally matches something. - `SOURCE_GVC` is still legitimately waiting on M9. + `SOURCE_GVC` has its writer since GAM-3: `POST /api/assets` takes `source=gvc_export` + (the only source a client may claim) with `origin_project_id` / `origin_reference_id`, + which is how Video Creator files a finished render back into the library. - **~~`extract_text` was specified and never built.~~** Built. PDF, `.docx`, `.pptx`, `.xlsx`, `.txt`/`.md`/`.csv`, one `DocumentPage` row per natural unit, and a `FROM_DOCUMENT` branch in `enrichment/source.py` placed **above** the poster branch — diff --git a/frontend/src/api/assets.ts b/frontend/src/api/assets.ts index 6321c34..f2cd8ba 100644 --- a/frontend/src/api/assets.ts +++ b/frontend/src/api/assets.ts @@ -21,6 +21,9 @@ export interface Asset { summary: string | null asset_type: AssetType source: string + /** GAM-3. The Video Creator project and render a `gvc_export` came from. */ + origin_project_id?: string | null + origin_reference_id?: string | null /** M7. Provenance on any of clip/promoted/extracted; only a live clip * (`source === 'clip'`) has no file of its own and needs `in_point`/`out_point`