Cleanup: infer a missing sex from a known-sex spouse (preview → approve)
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) <noreply@anthropic.com> Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
@@ -67,6 +67,16 @@ async def guess_gender(
|
|||||||
return [GenderProposal(**r) for r in rows]
|
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)
|
@router.post("/{tree_id}/cleanup/gender", response_model=CleanupResult)
|
||||||
async def apply_gender(
|
async def apply_gender(
|
||||||
tree_id: uuid.UUID, data: GenderApply, session: SessionDep, current: CurrentUser
|
tree_id: uuid.UUID, data: GenderApply, session: SessionDep, current: CurrentUser
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import uuid
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.enums import RelationshipType
|
||||||
from app.models.event import Event
|
from app.models.event import Event
|
||||||
from app.models.person import Name, Person
|
from app.models.person import Name, Person
|
||||||
|
from app.models.relationship import Relationship
|
||||||
from app.models.tree import Tree
|
from app.models.tree import Tree
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services import gedcom, privacy
|
from app.services import gedcom, privacy
|
||||||
@@ -182,6 +184,52 @@ async def guess_gender_by_name(
|
|||||||
return out
|
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(
|
async def apply_gender(
|
||||||
session: AsyncSession, *, actor: User, tree: Tree, updates: list[dict]
|
session: AsyncSession, *, actor: User, tree: Tree, updates: list[dict]
|
||||||
) -> int:
|
) -> int:
|
||||||
|
|||||||
@@ -51,6 +51,44 @@ async def test_deceased_preview_and_apply(client):
|
|||||||
assert old not in [r["person_id"] for r in prev2]
|
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
|
GED = b"""0 HEAD
|
||||||
0 @I1@ INDI
|
0 @I1@ INDI
|
||||||
1 NAME Josias /Moody/
|
1 NAME Josias /Moody/
|
||||||
|
|||||||
@@ -109,6 +109,15 @@ export default function CleanupPage() {
|
|||||||
setGenSel(new Set((data ?? []).map((g) => g.person_id)));
|
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() {
|
async function applyGender() {
|
||||||
const updates = (gender ?? [])
|
const updates = (gender ?? [])
|
||||||
.filter((g) => genSel.has(g.person_id))
|
.filter((g) => genSel.has(g.person_id))
|
||||||
@@ -246,10 +255,15 @@ export default function CleanupPage() {
|
|||||||
<Button variant="outline" onClick={guessGender}>
|
<Button variant="outline" onClick={guessGender}>
|
||||||
Guess from first name
|
Guess from first name
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="outline" onClick={guessGenderFromSpouse}>
|
||||||
|
Infer from spouse
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-[var(--muted)]">
|
<p className="text-xs text-[var(--muted)]">
|
||||||
“Guess from first name” uses a built-in name dictionary for people with no sex set;
|
“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.
|
“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.
|
||||||
</p>
|
</p>
|
||||||
{genMsg && <p className="text-sm text-bronze">{genMsg}</p>}
|
{genMsg && <p className="text-sm text-bronze">{genMsg}</p>}
|
||||||
{gender && (
|
{gender && (
|
||||||
|
|||||||
Vendored
+51
@@ -734,6 +734,26 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/v1/trees/{tree_id}/cleanup/gender": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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: {
|
apply_gender_api_v1_trees__tree_id__cleanup_gender_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -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": {
|
"/api/v1/trees/{tree_id}/cleanup/gender": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
Reference in New Issue
Block a user