people tags #387

This commit is contained in:
Hamid Reza Mohammadi 2026-09-28 08:54:42 +03:30
parent 1614b6e7ca
commit 5003f92f00
2 changed files with 244 additions and 1 deletions

View File

@ -0,0 +1,100 @@
using System.Collections.Generic;
namespace RMuseum.Models.Ganjoor.ViewModels
{
/// <summary>
/// deserialized shape of GanjoorPoemGeoDateTagCorrection.SuggestedPersonGraphJson - a brand new
/// person (optionally linked to other people, new or existing) suggested in one go alongside a
/// poem's geo/date/person tag. See that field's doc comment for the full JSON shape.
/// </summary>
public class PersonGraphSuggestion
{
/// <summary>
/// the node that ends up assigned to the tag's PersonId once approved
/// </summary>
public PersonGraphNode Person { get; set; }
/// <summary>
/// other people this submission also introduces or links to, referenced from Relations by
/// their LocalKey
/// </summary>
public List<PersonGraphNode> RelatedPeople { get; set; }
/// <summary>
/// kinship and/or non-family edges between any of Person/RelatedPeople's local keys
/// </summary>
public List<PersonGraphRelationEntry> Relations { get; set; }
}
/// <summary>
/// one person node in a PersonGraphSuggestion - either a link to an existing, already approved
/// GanjoorRelatedPerson (ExistingPersonId set, the rest ignored) or a brand new person to create
/// (ExistingPersonId null, the rest describing them)
/// </summary>
public class PersonGraphNode
{
/// <summary>
/// key used only to resolve Relations entries within this same submission - never stored
/// beyond approval time
/// </summary>
public string LocalKey { get; set; }
/// <summary>
/// set to link to an existing, already approved person instead of creating a new one - when
/// set, every other field on this node is ignored
/// </summary>
public int? ExistingPersonId { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public string WikiUrl { get; set; }
public int BirthYearInLHijri { get; set; }
public int DeathYearInLHijri { get; set; }
public bool ValidBirthDate { get; set; }
public bool ValidDeathDate { get; set; }
public int? BirthLocationId { get; set; }
public int? DeathLocationId { get; set; }
/// <summary>
/// see GanjoorRelatedPerson.FamilyTreeCaption - only meaningful for whichever node is meant
/// to be a tree's named root
/// </summary>
public string FamilyTreeCaption { get; set; }
}
/// <summary>
/// one edge in a PersonGraphSuggestion, between two nodes referenced by their LocalKey (Person1/
/// Person2 - matching PersonGraphNode.LocalKey, not a real database id yet). Kind picks which
/// enum RelationType/AffiliationType is parsed against and which live table the edge is
/// materialized into on approval.
/// </summary>
public class PersonGraphRelationEntry
{
/// <summary>
/// "family" (default, materialized as GanjoorPersonRelation - RelationType against
/// PersonRelationType) or "affiliation" (materialized as GanjoorPersonAffiliation -
/// AffiliationType against PersonAffiliationType)
/// </summary>
public string Kind { get; set; }
public string Person1 { get; set; }
public string Person2 { get; set; }
/// <summary>
/// parsed against PersonRelationType when Kind is "family"
/// </summary>
public string RelationType { get; set; }
/// <summary>
/// parsed against PersonAffiliationType when Kind is "affiliation"
/// </summary>
public string AffiliationType { get; set; }
/// <summary>
/// only meaningful for a family relation of type Ancestor - see GanjoorPersonRelation.DegreeHint
/// </summary>
public int? DegreeHint { get; set; }
public string Note { get; set; }
}
}

View File

@ -719,6 +719,20 @@ namespace RMuseum.Services.Implementation
approvedLocationId = newLocation.Id;
}
int? approvedPersonId = dbGeoDateTag.PersonId;
if (approvedPersonId == null && !string.IsNullOrWhiteSpace(dbGeoDateTag.SuggestedPersonGraphJson))
{
// a brand new person (and possibly their relatives/relations, some of which may
// also be brand new) - resolve/create everything the JSON describes and use
// whichever person node it designates as the one actually being tagged here
var personGraphResult = await _MaterializePersonGraphAsync(dbGeoDateTag.SuggestedPersonGraphJson);
if (!string.IsNullOrEmpty(personGraphResult.Item2))
{
return new RServiceResult<GanjoorPoemCorrectionViewModel>(null, personGraphResult.Item2);
}
approvedPersonId = personGraphResult.Item1;
}
var newTag = new PoemGeoDateTag()
{
PoemId = dbCorrection.PoemId,
@ -728,7 +742,7 @@ namespace RMuseum.Services.Implementation
LunarYear = dbGeoDateTag.LunarYear,
LunarMonth = dbGeoDateTag.LunarMonth,
LunarDay = dbGeoDateTag.LunarDay,
PersonId = dbGeoDateTag.PersonId,
PersonId = approvedPersonId,
IgnoreInCategory = dbGeoDateTag.IgnoreInCategory,
VerifiedDate = false,
MachineGenerated = false,
@ -822,5 +836,134 @@ namespace RMuseum.Services.Implementation
return new RServiceResult<GanjoorPoemCorrectionViewModel>(null, exp.ToString());
}
}
/// <summary>
/// materializes a GanjoorPoemGeoDateTagCorrection.SuggestedPersonGraphJson payload: creates
/// any brand new person nodes it describes (or reuses an existing one when a node names
/// ExistingPersonId), then creates the kinship (GanjoorPersonRelation) and/or non-family
/// (GanjoorPersonAffiliation) edges between them. Returns the resolved id of the "person"
/// node (the one that ends up assigned to the tag's PersonId), or an error message.
/// </summary>
/// <param name="json">SuggestedPersonGraphJson value - see its doc comment for the expected shape</param>
private async Task<Tuple<int, string>> _MaterializePersonGraphAsync(string json)
{
PersonGraphSuggestion graph;
try
{
graph = Newtonsoft.Json.JsonConvert.DeserializeObject<PersonGraphSuggestion>(json);
}
catch (Exception exp)
{
return new Tuple<int, string>(0, $"برچسب فرد پیشنهادی قابل تفسیر نیست: {exp.Message}");
}
if (graph?.Person == null || string.IsNullOrWhiteSpace(graph.Person.LocalKey))
{
return new Tuple<int, string>(0, "برچسب فرد پیشنهادی ناقص است.");
}
// first pass: resolve/create every node (the tagged person plus anyone else referenced),
// building a localKey -> real database id map for the relations pass below. A node
// referencing another not-yet-existing node has no real id to point at until this pass
// finishes, which is exactly why this travels as one JSON blob instead of separate rows.
var allNodes = new List<PersonGraphNode>() { graph.Person };
if (graph.RelatedPeople != null)
{
allNodes.AddRange(graph.RelatedPeople);
}
var localKeyToPersonId = new Dictionary<string, int>();
foreach (var node in allNodes)
{
if (string.IsNullOrWhiteSpace(node.LocalKey))
{
return new Tuple<int, string>(0, "یکی از افراد برچسب پیشنهادی بدون کلید محلی است.");
}
if (localKeyToPersonId.ContainsKey(node.LocalKey))
{
continue; // same local key reused (e.g. Person also listed in RelatedPeople) - already resolved
}
if (node.ExistingPersonId != null)
{
var existingPerson = await _context.GanjoorRelatedPersons.Where(p => p.Id == node.ExistingPersonId).AnyAsync();
if (!existingPerson)
{
return new Tuple<int, string>(0, $"فرد موجود با کد {node.ExistingPersonId} پیدا نشد.");
}
localKeyToPersonId[node.LocalKey] = node.ExistingPersonId.Value;
continue;
}
if (string.IsNullOrWhiteSpace(node.Name))
{
return new Tuple<int, string>(0, "نام یکی از افراد پیشنهادی وارد نشده است.");
}
var newPerson = new GanjoorRelatedPerson()
{
Name = node.Name.Trim(),
Description = node.Description,
WikiUrl = node.WikiUrl,
BirthYearInLHijri = node.BirthYearInLHijri,
DeathYearInLHijri = node.DeathYearInLHijri,
ValidBirthDate = node.ValidBirthDate,
ValidDeathDate = node.ValidDeathDate,
BirthLocationId = node.BirthLocationId,
DeathLocationId = node.DeathLocationId,
FamilyTreeCaption = string.IsNullOrWhiteSpace(node.FamilyTreeCaption) ? null : node.FamilyTreeCaption.Trim(),
MachineGenerated = false,
};
_context.GanjoorRelatedPersons.Add(newPerson);
await _context.SaveChangesAsync(); // need its Id below, other nodes/relations may reference it
localKeyToPersonId[node.LocalKey] = newPerson.Id;
}
// second pass: now that every node has a real id, create the edges between them
if (graph.Relations != null)
{
foreach (var relation in graph.Relations)
{
if (!localKeyToPersonId.TryGetValue(relation.Person1 ?? "", out int person1Id) ||
!localKeyToPersonId.TryGetValue(relation.Person2 ?? "", out int person2Id))
{
return new Tuple<int, string>(0, "یکی از روابط پیشنهادی به فردی خارج از این پیشنهاد اشاره می‌کند.");
}
if (string.Equals(relation.Kind, "affiliation", StringComparison.OrdinalIgnoreCase))
{
if (!Enum.TryParse<PersonAffiliationType>(relation.AffiliationType, out var affiliationType))
{
return new Tuple<int, string>(0, $"نوع رابطهٔ غیرخویشاوندی «{relation.AffiliationType}» نامعتبر است.");
}
_context.GanjoorPersonAffiliations.Add(new GanjoorPersonAffiliation()
{
Person1Id = person1Id,
Person2Id = person2Id,
AffiliationType = affiliationType,
Note = relation.Note,
});
}
else
{
if (!Enum.TryParse<PersonRelationType>(relation.RelationType, out var relationType))
{
return new Tuple<int, string>(0, $"نوع رابطهٔ خویشاوندی «{relation.RelationType}» نامعتبر است.");
}
_context.GanjoorPersonRelations.Add(new GanjoorPersonRelation()
{
Person1Id = person1Id,
Person2Id = person2Id,
RelationType = relationType,
DegreeHint = relation.DegreeHint,
Note = relation.Note,
});
}
}
await _context.SaveChangesAsync();
}
return new Tuple<int, string>(localKeyToPersonId[graph.Person.LocalKey], null);
}
}
}