generated from john/python-template
Implement Ver1 Stage3
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
"""Tests for Step 3 functional API routes."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
|
||||
from transcription.api.errors import register_error_handlers
|
||||
from transcription.api.routes import router
|
||||
|
||||
|
||||
def _build_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
register_error_handlers(app)
|
||||
app.include_router(router)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestFunctionalRoutes:
|
||||
"""Verify jobs/revisions/search/export route behavior."""
|
||||
|
||||
def test_get_jobs_returns_serialized_rows(self, monkeypatch):
|
||||
"""GET /api/jobs returns normalized job rows."""
|
||||
now = datetime.now(timezone.utc)
|
||||
job = SimpleNamespace(
|
||||
id=uuid4(),
|
||||
document_id=uuid4(),
|
||||
status=SimpleNamespace(value="queued"),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
monkeypatch.setattr("transcription.api.routes.list_jobs", lambda: [job])
|
||||
|
||||
client = TestClient(_build_app())
|
||||
response = client.get("/api/jobs")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert len(payload) == 1
|
||||
assert payload[0]["id"] == str(job.id)
|
||||
assert payload[0]["status"] == "queued"
|
||||
|
||||
def test_create_revision_returns_revision_payload(self, monkeypatch):
|
||||
"""POST /api/jobs/{job_id}/revisions returns created revision fields."""
|
||||
revision = SimpleNamespace(
|
||||
id=uuid4(),
|
||||
job_id=uuid4(),
|
||||
revision_number=2,
|
||||
text="edited text",
|
||||
source="user",
|
||||
accepted=False,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
monkeypatch.setattr("transcription.api.routes.add_revision", lambda **_kwargs: revision)
|
||||
|
||||
client = TestClient(_build_app())
|
||||
response = client.post(
|
||||
f"/api/jobs/{revision.job_id}/revisions",
|
||||
json={"text": "edited text", "source": "user", "accepted": False},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["id"] == str(revision.id)
|
||||
assert payload["revision_number"] == 2
|
||||
assert payload["text"] == "edited text"
|
||||
|
||||
def test_search_returns_results(self, monkeypatch):
|
||||
"""GET /api/search returns accepted transcript matches."""
|
||||
result = SimpleNamespace(
|
||||
id=uuid4(),
|
||||
job_id=uuid4(),
|
||||
revision_number=1,
|
||||
text="family archive",
|
||||
source="user",
|
||||
accepted=True,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
monkeypatch.setattr("transcription.api.routes.search_accepted_transcripts", lambda query: [result])
|
||||
|
||||
client = TestClient(_build_app())
|
||||
response = client.get("/api/search", params={"query": "archive"})
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert len(payload) == 1
|
||||
assert payload[0]["revision_id"] == str(result.id)
|
||||
assert payload[0]["accepted"] is True
|
||||
|
||||
def test_export_returns_count_and_records(self, monkeypatch):
|
||||
"""GET /api/export returns record count and payload list."""
|
||||
records = [
|
||||
{
|
||||
"job_id": str(uuid4()),
|
||||
"document_id": str(uuid4()),
|
||||
"filename": "letter.jpg",
|
||||
"revision_id": str(uuid4()),
|
||||
"revision_number": 1,
|
||||
"accepted": True,
|
||||
"source": "user",
|
||||
"text": "exported",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
]
|
||||
monkeypatch.setattr("transcription.api.routes.export_transcripts", lambda accepted_only=True: records)
|
||||
|
||||
client = TestClient(_build_app())
|
||||
response = client.get("/api/export")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["count"] == 1
|
||||
assert payload["accepted_only"] is True
|
||||
assert payload["records"] == records
|
||||
@@ -11,11 +11,11 @@ BOOK 1 had 54 pages; 14 chapters. BOOK 2 has 70 pages; 18 chapters. BOOK 1 con-
|
||||
sisted largely of first generation family history. BOOK 2 throws more light on
|
||||
the second generation. Sidney promises a BOOK 3 and that may begin to do justice
|
||||
to the third generation. We suggest that Sidney get the help of Louis Shinn
|
||||
who has a chapter in this book (Chapter 16 - The Last 25 Years on the Doumeeq
|
||||
who has a chapter in this book (Chapter 16 - The Last 25 Years on the Doumecq
|
||||
Plains. Louis has the gift of seeing, recalling and telling. One sentence in
|
||||
his chapter gives a great tribute to the Doumeeqers - so far as he knows no one
|
||||
on the Doumeeq Plains went on relief during the depression. That in a nutshell
|
||||
shows the sturdy character of the residents of the Doumeeq Plains.
|
||||
his chapter gives a great tribute to the Doumecqers--so far as he knows no one
|
||||
on the Doumecq Plains went on relief during the depression. That in a nutshell
|
||||
shows the sturdy character of the residents of the Doumecq Plains.
|
||||
|
||||
We promised in BOOK 1 that in BOOK 2 we would give the story of the trip of
|
||||
John E. Cochran and wife to Tennessee, Cuba and the Panama Canal. You will see
|
||||
@@ -32,7 +32,7 @@ enough pictures but we had to take only part of them. We think there are great
|
||||
possibilities in reproducing old pictures. We wish we had a Pickard group. Some
|
||||
Pickard descendant may wish to make a collection.
|
||||
|
||||
We are much impressed with the future possibilities of getting a complete geneol-
|
||||
We are much impressed with the future possibilities of getting a complete geneal-
|
||||
ogy of the Pickard family. Mr. Cochran has a fine chapter on the Pickards but
|
||||
to date we have not had the pleasure of finding all of the family dates. We had
|
||||
intended to give more family data in this book but it takes time to get the
|
||||
|
||||
@@ -18,7 +18,6 @@ the family newsletter two years ago.
|
||||
|
||||
Nome Alaska August 26, 1923
|
||||
My Dear Ethel et al.
|
||||
|
||||
I don't know when I did write or when you did
|
||||
but I am going to write now however and never
|
||||
the less. But I wish I could talk (I can yet but I
|
||||
@@ -27,7 +26,7 @@ and Polly sit up and listen and that little black
|
||||
rascal of yours would fairly sparkle with
|
||||
listening. Can't I see him listening now to all the
|
||||
yarns we told last summer?
|
||||
|
||||
[photo of people on ice with ship in background]
|
||||
You see, we-Miss Saville and I, took a trip north
|
||||
on the Buford and it was very interesting. We
|
||||
went north thru the Bering Strait into the Arctic and as far as the Ice Pack. There the captain
|
||||
@@ -43,14 +42,14 @@ all around it similar to a currycomb in coarseness; no ears but huge tusks of iv
|
||||
the most repulsive looking animals imaginable and tho I have always read about them I never
|
||||
expect such disagreeable looking creatures. They had a rough brown hairy skin and some of
|
||||
them looked warty. They must have weighed two ton at least. Ere we got them back to Nome
|
||||
to the natives they were getting extremely odiferous–in fact, you could scarcely stay on the
|
||||
to the natives they were getting extremely odiferous—in fact, you could scarcely stay on the
|
||||
ship with any degree of comfort unless you had per chance lost your sense of smell.
|
||||
|
||||
Then we went north to a few minutes beyond the 70th degree of latitude and thot [sic] for awhile
|
||||
we would go to Wrangell Island where some men from Stefflonsons [sic] ship were supposed to be
|
||||
stranded but we didn't get there and instead stopped at a small native village at Cape Serdz [sic] in
|
||||
Then we went north to a few minutes beyond the 70th degree of latitude and thot for awhile
|
||||
we would go to Wrangell Island where some men from Steffonsons ship were supposed to be
|
||||
stranded but we didn't get there and instead stopped at a small native village at Cape Serdz in
|
||||
Siberia. These Eskimo were very primitive. One white squaw man lived there and had for 23
|
||||
years. He was a Swede–who else could. Their houses were circular and built up with dirt 2 or
|
||||
years. He was a Swede--who else could. Their houses were circular and built up with dirt 2 or
|
||||
3 feet and then skins were stretched over it and weighted down with rocks. Inside, the room
|
||||
was partitioned off at the sides with skins for sleeping quarters. In the main part they had the
|
||||
fire on the ground and the fish drying on lines and the skins hanging around and the dogs and
|
||||
@@ -65,22 +64,22 @@ The other place we stopped was at Whalen, a trading post in Siberia. There these
|
||||
went wild. They rushed helter-skelter, hither and thither, here and there, trying to find
|
||||
something to buy. Prices raised right before your eyes. One would but something for $1.00
|
||||
and the next might have to pay $2.00, $4.00 or $10.00. That made no difference. They had to
|
||||
have it. One man I was sort of taking care of, tho [sic] he had his son along for the purpose,
|
||||
bought 2 ivory tusks, 1 pup, 2 moccasins, 3 or 4 billi[illegible]s, 6 or 8 ivory and silver rings, one
|
||||
have it. One man I was sort of taking care of, tho he had his son along for the purpose,
|
||||
bought 2 ivory tusks, 1 pup, 2 moccasins, 3 or 4 billikens, 6 or 8 ivory and silver rings, one
|
||||
fishing line, hooks, floats, etc. and two bird slings. The slings have rocks at the end and the
|
||||
little natives throw them at the flocks of geese and ducks which fly close over the village and
|
||||
the slings entangle their wings and legs, sometimes more than one, and they can't fly. They
|
||||
come down and the natives capture them. There was more junk brot [sic] aboard than baggage, I
|
||||
come down and the natives capture them. There was more junk brot aboard than baggage, I
|
||||
do believe. And they say that at the first stop it was worse than here. The red flag was flying
|
||||
over Whalen and the Russian soldiers were there–a few, one or two or three, I forget the
|
||||
over Whalen and the Russian soldiers were there—a few, one or two or three, I forget the
|
||||
number.
|
||||
|
||||
We got home yesterday morning at 5 a.m. but missed the first lighter in so had to stay out
|
||||
until 2:30. The girls had prepared a big meal for us and invited up the Hartfords and then let
|
||||
us talk. Miss Saville talked quite a bit. Any how if you folks don't like this I don't care, it is
|
||||
all I had to write about and I know Buster'd [sic] listen anyway and I'd soak ole Peter's head if he
|
||||
all I had to write about and I know Buster'ud listen anyway and I'd soak ole Peter's head if he
|
||||
didn't and Polly would in my lap and I don't know much about the youngest one of yours so
|
||||
likely he would be squawling. But we did surely enjoy our trip and were gone just long enuf [sic].
|
||||
likely he would be squawling. But we did surely enjoy our trip and were gone just long enuf.
|
||||
|
||||
I expect there were 150 passengers on board and almost or more of the crew and helpers. We
|
||||
had a stateroom down next to the kitchen and 'twas pretty fierce for odor at times.
|
||||
@@ -109,7 +108,7 @@ Ome
|
||||
|
||||
Reprinted from Cochran Chronicles, Volume 9, Number 1, November 1986
|
||||
|
||||
© [inserted: JECFA] 1986
|
||||
© JECFA 1986
|
||||
|
||||
Up
|
||||
|
||||
|
||||
@@ -4,28 +4,28 @@ model: google/gemini-2.5-flash
|
||||
---
|
||||
JOHN ISBILL
|
||||
R. T. MOSER
|
||||
|
||||
ISBILL & MOSER
|
||||
DEALERS IN
|
||||
GENERAL MERCHANDISE
|
||||
|
||||
Vonore, Tenn., Jany 27- 1913
|
||||
Dear Much Aunt Louie
|
||||
How are you a
|
||||
few nights ago I sewed a
|
||||
letter from your folks, so
|
||||
Vonore, Tenn. January 27 – 1913
|
||||
Dear Uncle Aunt Adeline
|
||||
Was at home a
|
||||
few nights ago & saw a
|
||||
letter from your folks, So
|
||||
I decided to write you
|
||||
a few lines myself ok
|
||||
I am contemplateing a
|
||||
I am contemplate a
|
||||
trip out west next summer
|
||||
& I want Some Olders to go
|
||||
where I and them.
|
||||
|
||||
I am getting
|
||||
& [inserted: I] would like of adders [sic] to go
|
||||
where I [inserted: am] them.
|
||||
Am getting
|
||||
up in years & unmarried
|
||||
so you see the object of
|
||||
my trip, is to get a bunch
|
||||
of Young & old maids
|
||||
& widows out there. I
|
||||
my trip is to get a wife
|
||||
& if there is any old maid
|
||||
or widows out there, I
|
||||
want you to kiss them
|
||||
at my fans [sic] mug as they
|
||||
as soon as I get there
|
||||
at my [inserted: mind] for me at there [sic]
|
||||
as soon as I get there.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Tests for Step 3 library services (revisions, search, export)."""
|
||||
|
||||
from sqlmodel import select
|
||||
import pytest
|
||||
|
||||
from transcription.models import Document, Job, JobStatus, Transcript, TranscriptRevision
|
||||
from transcription.services.library import accept_revision, add_revision, export_transcripts, list_revisions, search_accepted_transcripts
|
||||
|
||||
|
||||
def _create_job(session) -> Job:
|
||||
document = Document(filename="letter.jpg", file_path="uploads/letter.jpg")
|
||||
session.add(document)
|
||||
session.commit()
|
||||
session.refresh(document)
|
||||
|
||||
job = Job(document_id=document.id, status=JobStatus.TRANSCRIBED)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
session.refresh(job)
|
||||
return job
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestRevisionHistoryBehavior:
|
||||
"""Verify revision append/accept behavior."""
|
||||
|
||||
def test_add_revision_appends_incrementing_revision_numbers(self, session):
|
||||
"""add_revision creates immutable incrementing revisions per job."""
|
||||
job = _create_job(session)
|
||||
|
||||
r1 = add_revision(job_id=job.id, text="first", session=session)
|
||||
r2 = add_revision(job_id=job.id, text="second", session=session)
|
||||
|
||||
revisions = list_revisions(job_id=job.id, session=session)
|
||||
|
||||
assert r1.revision_number == 1
|
||||
assert r2.revision_number == 2
|
||||
assert [revision.revision_number for revision in revisions] == [1, 2]
|
||||
|
||||
def test_accept_revision_marks_selected_revision_and_sets_job_completed(self, session):
|
||||
"""accept_revision marks one revision accepted and transitions job to completed."""
|
||||
job = _create_job(session)
|
||||
r1 = add_revision(job_id=job.id, text="v1", session=session)
|
||||
r2 = add_revision(job_id=job.id, text="v2", session=session)
|
||||
|
||||
accepted = accept_revision(revision_id=r2.id, session=session)
|
||||
session.refresh(job)
|
||||
|
||||
all_revisions = list(session.exec(select(TranscriptRevision).where(TranscriptRevision.job_id == job.id)).all())
|
||||
accepted_flags = {revision.id: revision.accepted for revision in all_revisions}
|
||||
transcript = session.exec(select(Transcript).where(Transcript.job_id == job.id)).first()
|
||||
|
||||
assert accepted.id == r2.id
|
||||
assert accepted_flags[r1.id] is False
|
||||
assert accepted_flags[r2.id] is True
|
||||
assert job.status == JobStatus.COMPLETED
|
||||
assert transcript is not None
|
||||
assert transcript.text == "v2"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestSearchAndExportBehavior:
|
||||
"""Verify accepted-only search and export semantics."""
|
||||
|
||||
def test_search_returns_only_accepted_revisions(self, session):
|
||||
"""search_accepted_transcripts filters out non-accepted revisions."""
|
||||
job = _create_job(session)
|
||||
draft = add_revision(job_id=job.id, text="family archive draft", session=session)
|
||||
accepted = add_revision(job_id=job.id, text="family archive final", accepted=True, session=session)
|
||||
|
||||
results = search_accepted_transcripts(query="archive", session=session)
|
||||
|
||||
assert results
|
||||
result_ids = {result.id for result in results}
|
||||
assert accepted.id in result_ids
|
||||
assert draft.id not in result_ids
|
||||
|
||||
def test_export_returns_serializable_records_for_accepted_revisions(self, session):
|
||||
"""export_transcripts returns expected fields for accepted-only export."""
|
||||
job = _create_job(session)
|
||||
accepted = add_revision(job_id=job.id, text="export me", accepted=True, session=session)
|
||||
|
||||
records = export_transcripts(accepted_only=True, session=session)
|
||||
|
||||
assert len(records) == 1
|
||||
record = records[0]
|
||||
assert record["job_id"] == str(job.id)
|
||||
assert record["revision_id"] == str(accepted.id)
|
||||
assert record["accepted"] is True
|
||||
assert record["text"] == "export me"
|
||||
@@ -8,7 +8,7 @@ from sqlmodel import select
|
||||
|
||||
from transcription.config import Settings
|
||||
from transcription.errors import AppError, ErrorCategory
|
||||
from transcription.models import Document, Job, JobStatus, Transcript
|
||||
from transcription.models import Document, Job, JobStatus, Transcript, TranscriptRevision
|
||||
from transcription.providers.base import TranscriptionResult
|
||||
from transcription.worker import process_next_queued_job, run_worker_loop
|
||||
|
||||
@@ -74,12 +74,21 @@ class TestWorkerSuccessPath:
|
||||
|
||||
process_next_queued_job(session=session)
|
||||
|
||||
transcript = session.exec(
|
||||
select(Transcript).where(Transcript.job_id == job.id)
|
||||
transcript = session.exec(select(Transcript).where(Transcript.job_id == job.id)).first()
|
||||
revision = session.exec(
|
||||
select(TranscriptRevision)
|
||||
.where(TranscriptRevision.job_id == job.id)
|
||||
.order_by(TranscriptRevision.revision_number)
|
||||
).first()
|
||||
|
||||
assert transcript is not None
|
||||
assert transcript.text == "Transcript body"
|
||||
assert transcript.error_detail is None
|
||||
assert revision is not None
|
||||
assert revision.revision_number == 1
|
||||
assert revision.text == "Transcript body"
|
||||
assert revision.source == "worker"
|
||||
assert revision.accepted is False
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
+40
-1
@@ -5,7 +5,7 @@ from uuid import UUID
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from transcription.models import Document, Job, JobStatus, Transcript
|
||||
from transcription.models import Document, Job, JobStatus, Transcript, TranscriptRevision
|
||||
|
||||
|
||||
def _make_document(**overrides) -> Document:
|
||||
@@ -89,6 +89,12 @@ class TestJobModel:
|
||||
session.refresh(job)
|
||||
assert job.status == JobStatus.TRANSCRIBED
|
||||
|
||||
job.status = JobStatus.COMPLETED
|
||||
session.add(job)
|
||||
session.commit()
|
||||
session.refresh(job)
|
||||
assert job.status == JobStatus.COMPLETED
|
||||
|
||||
def test_transitions_to_failed(self, session):
|
||||
"""Status updates from processing to failed."""
|
||||
doc = _persist_document(session)
|
||||
@@ -152,6 +158,27 @@ class TestTranscriptModel:
|
||||
session.commit()
|
||||
|
||||
|
||||
class TestTranscriptRevisionModel:
|
||||
"""Verify transcript revision persistence and defaults."""
|
||||
|
||||
def test_revision_defaults_and_persistence(self, session):
|
||||
"""Revision records persist with revision metadata and defaults."""
|
||||
doc = _persist_document(session)
|
||||
job = _persist_job(session, doc)
|
||||
|
||||
revision = TranscriptRevision(job_id=job.id, revision_number=1, text="Rev text")
|
||||
session.add(revision)
|
||||
session.commit()
|
||||
session.refresh(revision)
|
||||
|
||||
fetched = session.get(TranscriptRevision, revision.id)
|
||||
assert fetched is not None
|
||||
assert fetched.revision_number == 1
|
||||
assert fetched.text == "Rev text"
|
||||
assert fetched.source == "worker"
|
||||
assert fetched.accepted is False
|
||||
|
||||
|
||||
class TestRelationships:
|
||||
"""Verify SQLModel relationship navigation between models."""
|
||||
|
||||
@@ -177,3 +204,15 @@ class TestRelationships:
|
||||
assert job.transcript is not None
|
||||
assert isinstance(job.transcript, Transcript)
|
||||
assert job.transcript.text == "Transcribed text"
|
||||
|
||||
def test_job_exposes_revisions(self, session):
|
||||
"""job.revisions returns revision history linked to the Job."""
|
||||
doc = _persist_document(session)
|
||||
job = _persist_job(session, doc)
|
||||
session.add(TranscriptRevision(job_id=job.id, revision_number=1, text="v1"))
|
||||
session.add(TranscriptRevision(job_id=job.id, revision_number=2, text="v2", accepted=True))
|
||||
session.commit()
|
||||
|
||||
session.refresh(job)
|
||||
assert len(job.revisions) == 2
|
||||
assert all(isinstance(revision, TranscriptRevision) for revision in job.revisions)
|
||||
|
||||
@@ -19,14 +19,17 @@ MVP_REQUIREMENT_TEST_MAP: dict[str, list[str]] = {
|
||||
"REQ-3": [
|
||||
"tests/services/test_worker.py",
|
||||
"tests/ui/test_jobs_page.py",
|
||||
"tests/services/test_library.py",
|
||||
],
|
||||
"REQ-4": [
|
||||
"tests/services/test_worker.py",
|
||||
"tests/integration/test_pipeline_flow.py",
|
||||
"tests/services/test_library.py",
|
||||
],
|
||||
"REQ-5": [
|
||||
"tests/ui/test_jobs_page.py",
|
||||
"tests/ui/test_pages_registration.py",
|
||||
"tests/api/test_routes.py",
|
||||
],
|
||||
"REQ-6": [
|
||||
"tests/test_app.py",
|
||||
@@ -36,6 +39,9 @@ MVP_REQUIREMENT_TEST_MAP: dict[str, list[str]] = {
|
||||
"tests/test_app.py",
|
||||
"tests/test_config.py",
|
||||
],
|
||||
"REQ-11": [
|
||||
"tests/services/test_library.py",
|
||||
],
|
||||
"REQ-12": [
|
||||
"tests/test_prompts.py",
|
||||
"tests/services/test_transcription.py",
|
||||
|
||||
Reference in New Issue
Block a user