From 05d2773e259dbd674e585bd8f2d00bfa72f3804b Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Tue, 9 Jun 2026 10:59:08 -0400 Subject: [PATCH] =?UTF-8?q?Cleanup:=20infer=20a=20missing=20sex=20from=20a?= =?UTF-8?q?=20known-sex=20spouse=20(preview=20=E2=86=92=20approve)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unset sex renders blue (male-colored), which is misleading next to a confirmed male partner. Add a Cleanup action that proposes the opposite sex for an unset partner of someone whose sex is set (couples are opposite-sex in practice — a confirmed-male husband ⇒ a female wife). People whose known partners disagree are skipped as ambiguous. It's a preview the user reviews and approves in the Cleanup tool (reusing the existing gender apply path + audit) — not an autonomous write. Backend: guess_gender_by_spouse + GET /cleanup/gender/from-spouse. Frontend: an "Infer from spouse" button feeding the existing proposal list. Test covers propose-opposite, skip-no-partner, skip-already-set, apply, and re-preview. Full suite 73 passed; frontend build clean. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Justin Paul --- backend/app/api/v1/cleanup.py | 10 +++++ backend/app/services/cleanup_service.py | 48 ++++++++++++++++++++++ backend/tests/test_cleanup.py | 38 ++++++++++++++++++ frontend/app/trees/[id]/cleanup/page.tsx | 18 ++++++++- frontend/lib/api/schema.d.ts | 51 ++++++++++++++++++++++++ frontend/openapi.json | 48 ++++++++++++++++++++++ 6 files changed, 211 insertions(+), 2 deletions(-) diff --git a/backend/app/api/v1/cleanup.py b/backend/app/api/v1/cleanup.py index 1855ddb..e3d6cd5 100644 --- a/backend/app/api/v1/cleanup.py +++ b/backend/app/api/v1/cleanup.py @@ -67,6 +67,16 @@ async def guess_gender( return [GenderProposal(**r) for r in rows] +@router.get("/{tree_id}/cleanup/gender/from-spouse", response_model=list[GenderProposal]) +async def guess_gender_from_spouse( + tree_id: uuid.UUID, session: SessionDep, current: CurrentUser +) -> list[GenderProposal]: + """Infer a missing sex from a partner whose sex is set (opposite-sex couple).""" + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + rows = await cleanup_service.guess_gender_by_spouse(session, actor=current, tree=tree) + return [GenderProposal(**r) for r in rows] + + @router.post("/{tree_id}/cleanup/gender", response_model=CleanupResult) async def apply_gender( tree_id: uuid.UUID, data: GenderApply, session: SessionDep, current: CurrentUser diff --git a/backend/app/services/cleanup_service.py b/backend/app/services/cleanup_service.py index 5761728..befee4e 100644 --- a/backend/app/services/cleanup_service.py +++ b/backend/app/services/cleanup_service.py @@ -12,8 +12,10 @@ import uuid from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.models.enums import RelationshipType from app.models.event import Event from app.models.person import Name, Person +from app.models.relationship import Relationship from app.models.tree import Tree from app.models.user import User from app.services import gedcom, privacy @@ -182,6 +184,52 @@ async def guess_gender_by_name( return out +async def guess_gender_by_spouse( + session: AsyncSession, *, actor: User, tree: Tree +) -> list[dict]: + """Infer the sex of a person who has none set from a partner whose sex IS set + (couples in a tree are opposite-sex in practice — e.g. a confirmed-male + husband implies a female wife). People whose known partners disagree are + ambiguous and skipped; the result is a preview to review, not an auto-write.""" + await _require_editor(session, actor=actor, tree=tree) + persons = await _persons(session, tree.id) + gender = {p.id: p.gender for p in persons} + names = await _primary_name_by_person(session, tree.id) + rels = ( + await session.execute( + select(Relationship).where( + Relationship.tree_id == tree.id, + Relationship.deleted_at.is_(None), + Relationship.type == RelationshipType.partnership, + ) + ) + ).scalars().all() + opp = {"male": "female", "female": "male"} + proposals: dict[uuid.UUID, set[str]] = {} + for r in rels: + for me_id, other_id in ( + (r.person_from_id, r.person_to_id), + (r.person_to_id, r.person_from_id), + ): + if gender.get(me_id): + continue # this person already has a sex + other_sex = str(gender.get(other_id) or "") + if other_sex in opp: + proposals.setdefault(me_id, set()).add(opp[other_sex]) + out: list[dict] = [] + for pid, sexes in proposals.items(): + if len(sexes) != 1: + continue # partners of differing known sex → ambiguous + nm = names.get(pid) + if nm is None: + continue + out.append( + {"person_id": str(pid), "name": _display(nm), "proposed_gender": next(iter(sexes))} + ) + out.sort(key=lambda r: r["name"]) + return out + + async def apply_gender( session: AsyncSession, *, actor: User, tree: Tree, updates: list[dict] ) -> int: diff --git a/backend/tests/test_cleanup.py b/backend/tests/test_cleanup.py index 61c426a..d9338af 100644 --- a/backend/tests/test_cleanup.py +++ b/backend/tests/test_cleanup.py @@ -51,6 +51,44 @@ async def test_deceased_preview_and_apply(client): assert old not in [r["person_id"] for r in prev2] +async def test_gender_from_spouse_preview_and_apply(client): + h, tid = await _tree(client, "cl-spouse@example.com") + husband = ( + await client.post( + f"/api/v1/trees/{tid}/persons", + json={"given": "Otto", "surname": "Frey", "gender": "male"}, + headers=h, + ) + ).json()["id"] + wife = await _person(client, h, tid, "Bea", "Frey") # no sex + loner = await _person(client, h, tid, "Nyx", "Alone") # no sex, no partner + await client.post( + f"/api/v1/trees/{tid}/relationships", + json={"type": "partnership", "person_from_id": husband, "person_to_id": wife}, + headers=h, + ) + + prev = (await client.get(f"/api/v1/trees/{tid}/cleanup/gender/from-spouse", headers=h)).json() + by = {r["person_id"]: r["proposed_gender"] for r in prev} + assert by.get(wife) == "female" # opposite of the confirmed-male husband + assert loner not in by # no known-sex partner → not proposed + assert husband not in by # already has a sex + + r = await client.post( + f"/api/v1/trees/{tid}/cleanup/gender", + json={"updates": [{"person_id": wife, "gender": "female"}]}, + headers=h, + ) + assert r.status_code == 200 and r.json()["updated"] == 1 + assert ( + await client.get(f"/api/v1/trees/{tid}/persons/{wife}", headers=h) + ).json()["gender"] == "female" + + # Once set, the wife is no longer proposed. + prev2 = (await client.get(f"/api/v1/trees/{tid}/cleanup/gender/from-spouse", headers=h)).json() + assert wife not in [r["person_id"] for r in prev2] + + GED = b"""0 HEAD 0 @I1@ INDI 1 NAME Josias /Moody/ diff --git a/frontend/app/trees/[id]/cleanup/page.tsx b/frontend/app/trees/[id]/cleanup/page.tsx index a2dd59d..f2d8085 100644 --- a/frontend/app/trees/[id]/cleanup/page.tsx +++ b/frontend/app/trees/[id]/cleanup/page.tsx @@ -109,6 +109,15 @@ export default function CleanupPage() { setGenSel(new Set((data ?? []).map((g) => g.person_id))); } + async function guessGenderFromSpouse() { + setGenMsg(null); + const { data } = await api.GET("/api/v1/trees/{tree_id}/cleanup/gender/from-spouse", { + params: { path: { tree_id: treeId } }, + }); + setGender(data ?? []); + setGenSel(new Set((data ?? []).map((g) => g.person_id))); + } + async function applyGender() { const updates = (gender ?? []) .filter((g) => genSel.has(g.person_id)) @@ -246,10 +255,15 @@ export default function CleanupPage() { +

- “Guess from first name” uses a built-in name dictionary for people with no sex set; - ambiguous names (Marion, Frances, …) are left for you to decide. + “Guess from first name” uses a built-in name dictionary for people with no sex set. + “Infer from spouse” sets the opposite sex for an unset partner of someone whose sex is + known (e.g. a confirmed-male husband ⇒ a female wife) — review before applying, since + it assumes opposite-sex couples.

{genMsg &&

{genMsg}

} {gender && ( diff --git a/frontend/lib/api/schema.d.ts b/frontend/lib/api/schema.d.ts index a126380..582c5a5 100644 --- a/frontend/lib/api/schema.d.ts +++ b/frontend/lib/api/schema.d.ts @@ -734,6 +734,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/trees/{tree_id}/cleanup/gender/from-spouse": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Guess Gender From Spouse + * @description Infer a missing sex from a partner whose sex is set (opposite-sex couple). + */ + get: operations["guess_gender_from_spouse_api_v1_trees__tree_id__cleanup_gender_from_spouse_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/trees/{tree_id}/cleanup/gender": { parameters: { query?: never; @@ -3687,6 +3707,37 @@ export interface operations { }; }; }; + guess_gender_from_spouse_api_v1_trees__tree_id__cleanup_gender_from_spouse_get: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GenderProposal"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; apply_gender_api_v1_trees__tree_id__cleanup_gender_post: { parameters: { query?: never; diff --git a/frontend/openapi.json b/frontend/openapi.json index e6be1a3..bcc4b0a 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -2915,6 +2915,54 @@ } } }, + "/api/v1/trees/{tree_id}/cleanup/gender/from-spouse": { + "get": { + "tags": [ + "cleanup" + ], + "summary": "Guess Gender From Spouse", + "description": "Infer a missing sex from a partner whose sex is set (opposite-sex couple).", + "operationId": "guess_gender_from_spouse_api_v1_trees__tree_id__cleanup_gender_from_spouse_get", + "parameters": [ + { + "name": "tree_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Tree Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GenderProposal" + }, + "title": "Response Guess Gender From Spouse Api V1 Trees Tree Id Cleanup Gender From Spouse Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/trees/{tree_id}/cleanup/gender": { "post": { "tags": [