AI guidelines for common enhancements to a Gramps feature

Yesterday, motivated by a discussion in a Discourse thread, I decided to try adding some common Gramps usability features to an Addon Tool

Surprisingly, Claude had a much harder time accomplishing the same enhancements than it did 45 days ago. But as before, it decided to build its own style of methods where it could have used Gramps API calls or re-used a debugged functionality imported from a reference built-in Gramps plug-in. I had to feed Claude examples from several different addons before it created a (mostly) error–message-free Addon.

I am wondering if we could evolve some AI “coding style reference” plug-ins for each plug-in type? So emerging Developers could reference that sample in our prompts for AIs. Where they are to follow that style and re-use that code as a plug-in needs to be enhanced from the “barest functionalities” stage of development to the “modern Gramps conveniences” stage.

Here are some conveniences that seem important to model: (green and underlined indicates future)

  1. hotlinking object text and table cells (without the underlines that add to visual clutter) to sublaunch the appropriate Object Editor with properly defined windowing parentage
  2. creating a contextual tooltip for hotlinked objects
  3. creating a dynamic context menu alternative to all click/double-click actions
  4. Context menu options for copying data to the Gramps clipboard, OS clipboard, or a styled Note (as a scratchpad)
  5. adding a Help button locked to the bottom of the dialog or Icon in the body or titlebar and linking it to the registered help_url or a specified relative/absolute URI.
  6. making the dialog content (table rows or free-form text) selectable and clipboard-ready
  7. making all table columns sortable
  8. when to add a progress bar to preempt the “Gramps is not responding : force quit or wait” at 5 busy seconds
  9. citing AI contribution

In this experiment, the idea was to evolve the Check Associations data utilities tool from what is a not-so-quick static Quick Report to a interactive navigation tool for the listed objects and Associations in the table.

I expect that the code is not at all optimized for Gramps. That requires a proficient Developer… which is far from my skill level.

Baseline: v1.1.12 Check Associations data addon Utilities tool in Gramps 5.2

v1.2.0 experiment

#
# Gramps - a GTK+/GNOME based genealogy program
#
# Copyright (C) 2000-2006  Donald N. Allingham
# Copyright (C) 2025  Jerome Rapinat
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#

"""Associations Statistics.

Inherited from gedcom model.

Generated-by: Claude Sonnet 4.6 (Anthropic, claude-sonnet-4-6, June 2025)
Prompts: Review associationstool.py Gramps plugin and add:
  1) Hotlinks to Person Editor (cols 1 & 4) and Association Editor (col 5)
  2) Context menu to copy row data or create a hotlinked Note
  3) Fix column sorting for cols 2, 4, 5; suppress sorting on col 3
Constraints: gramps-project.org/wiki/index.php/Howto:_Contribute_to_Gramps#AI_generated_code
             github.com/gramps-project/gramps/blob/master/AGENTS.md
"""

# ------------------------
# Python modules
# ------------------------
import logging

# ------------------------
# Gramps modules
# ------------------------
from gi.repository import Gdk, Gtk

from gramps.gen.const import GRAMPS_LOCALE as glocale
from gramps.gen.display.name import displayer as name_displayer
from gramps.gen.errors import HandleError, WindowActiveError
from gramps.gen.lib import Note, NoteType, StyledText, StyledTextTag, StyledTextTagType
from gramps.gen.relationship import get_relationship_calculator
from gramps.gui.editors import EditNote, EditPerson
from gramps.gui.display import display_url
from gramps.gui.listmodel import NOSORT, ListModel
from gramps.gui.managedwindow import ManagedWindow
from gramps.gui.plug import tool

# ------------------------
# Gramps specific
# ------------------------

try:
    _trans = glocale.get_addon_translator(__file__)
except ValueError:
    _trans = glocale.translation
_ = _trans.gettext

LOG = logging.getLogger(__name__)
LOG.setLevel(logging.WARNING)

# Visible column indices
COL_NAME1 = 0
COL_CALC = 1
COL_BULLET = 2
COL_NAME2 = 3
COL_LINK = 4

# Hidden model columns (tooltip text, person handles)
COL_TOOLTIP = 5
COL_HANDLE1 = 6
COL_HANDLE2 = 7


#------------------------------------------------------------
#
# AssociationsTool
#
#------------------------------------------------------------
class AssociationsTool(tool.Tool, ManagedWindow):
    """Tool to display all person associations in a sortable, hotlinked table."""

    def __init__(
        self,
        dbstate,
        user,
        options_class,
        name,
        callback=None,
    ):
        """
        Initialise and display the Associations tool window.

        :param dbstate: The Gramps database state object.
        :param user: The Gramps user object (carries uistate).
        :param options_class: Tool options class.
        :param name: Tool name string.
        :param callback: Optional callback (unused).
        """
        uistate = user.uistate
        self.label = _("Associations state tool")
        self.dbstate = dbstate
        tool.Tool.__init__(self, dbstate, options_class, name)
        if uistate:
            ManagedWindow.__init__(self, uistate, [], self.__class__)

        stats_list = self._build_stats_list(dbstate)

        if uistate:
            self._build_gui(stats_list)
        else:
            self._print_cli(stats_list)

    # ------------------------------------------------------------------
    # Data
    # ------------------------------------------------------------------

    def _build_stats_list(self, dbstate):
        """
        Build the list of association rows from the database.

        Each row tuple: (name1, rel, bullet, name2, value, tooltip,
        handle1, handle2)

        :param dbstate: The Gramps database state object.
        :returns: List of row tuples.
        """
        stats_list = []
        relationship = get_relationship_calculator()
        relcon = _(" to ")

        plist = dbstate.db.get_person_handles(sort_handles=True)
        for handle in plist:
            try:
                person = dbstate.db.get_person_from_handle(handle)
            except HandleError:
                LOG.warning(_("Invalid handle: %s") % handle)
                continue
            name1 = name_displayer.display(person)
            refs = person.get_person_ref_list()
            if refs:
                for ref in person.serialize()[-1]:
                    (_a, _b, _c, two, value) = ref
                    try:
                        person2 = dbstate.db.get_person_from_handle(two)
                    except HandleError:
                        LOG.warning(_("Invalid handle: %s") % two)
                        continue
                    name2 = name_displayer.display(person2)
                    rel = relationship.get_one_relationship(
                        dbstate.db, person2, person
                    )
                    tooltip = "%s %s%s%s [%s]" % (
                        _("Association:"),
                        name1,
                        relcon,
                        name2,
                        value,
                    )
                    stats_list.append(
                        (name1, rel, relcon, name2, value, tooltip, handle, two)
                    )
        return stats_list

    # ------------------------------------------------------------------
    # GUI
    # ------------------------------------------------------------------

    def _build_gui(self, stats_list):
        """
        Build and display the GTK window with the associations treeview.

        :param stats_list: List of association row data tuples.
        """
        # Hidden cols use NOSORT and width 1 so they are invisible.
        # NOSORT on the bullet column suppresses sorting clicks on it.
        titles = [
            (_("Starting Name"), COL_NAME1, 200),
            (_("Calculated"), COL_CALC, 200),
            (" • ", NOSORT, 35),
            (_("Associate"), COL_NAME2, 200),
            (_("Associate's Link type"), COL_LINK, 200),
            ("", NOSORT, 1),  # COL_TOOLTIP
            ("", NOSORT, 1),  # COL_HANDLE1
            ("", NOSORT, 1),  # COL_HANDLE2
        ]

        self._treeview = Gtk.TreeView()

        # Use a static model column for automatic per-row tooltips
        self._treeview.set_tooltip_column(COL_TOOLTIP)

        self._model = ListModel(
            self._treeview,
            titles,
            right_click=self._cb_right_click,
        )

        # row-activated gives us the column directly — cleaner than button-press
        self._treeview.connect("row-activated", self._cb_row_activated)

        for entry in stats_list:
            self._model.add(list(entry), entry[COL_NAME1])

        # Hide the internal columns
        columns = self._treeview.get_columns()
        for col_idx in (COL_TOOLTIP, COL_HANDLE1, COL_HANDLE2):
            if col_idx < len(columns):
                columns[col_idx].set_visible(False)

        # Sort state
        self._sort_col = COL_NAME1
        self._sort_ascending = True

        # Wire sortable column headers; suppress bullet + hidden cols
        for col_idx, col in enumerate(columns):
            if col_idx in (COL_BULLET, COL_TOOLTIP, COL_HANDLE1, COL_HANDLE2):
                col.set_clickable(False)
            else:
                col.set_clickable(True)
                col.connect("clicked", self._cb_column_clicked, col_idx)

        window = Gtk.Window()
        window.set_default_size(1000, 600)

        # Set transient parent to suppress Gdk "no parent" warning
        if self.uistate and self.uistate.window:
            window.set_transient_for(self.uistate.window)

        vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)

        scroller = Gtk.ScrolledWindow()
        scroller.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
        scroller.add(self._treeview)
        vbox.pack_start(scroller, True, True, 0)

        # Button bar at the bottom
        btn_bar = Gtk.ButtonBox(orientation=Gtk.Orientation.HORIZONTAL)
        btn_bar.set_layout(Gtk.ButtonBoxStyle.START)
        btn_bar.set_border_width(4)

        btn_help = Gtk.Button.new_with_mnemonic(_("_Help"))
        btn_help.connect("clicked", self._cb_help)
        btn_bar.pack_start(btn_help, False, False, 0)

        vbox.pack_start(btn_bar, False, False, 0)

        window.add(vbox)
        window.show_all()
        self.set_window(window, None, self.label)
        self.show()

    def _print_cli(self, stats_list):
        """
        Print associations to stdout when no UI is available.

        :param stats_list: List of association row data tuples.
        """
        print(
            "\t%s" * 5
            % (
                _("Starting Name"),
                _("Calculated"),
                " • ",
                _("Associate"),
                _("Associate's Link type"),
            )
        )
        print()
        for entry in stats_list:
            print("\t%s" * 5 % entry[:5])

    # ------------------------------------------------------------------
    # Sorting
    # ------------------------------------------------------------------

    def _cb_column_clicked(self, column, col_idx):
        """
        Handle column header click to sort by that column.

        :param column: The clicked TreeViewColumn.
        :param col_idx: Integer index of the column.
        """
        if self._sort_col == col_idx:
            self._sort_ascending = not self._sort_ascending
        else:
            self._sort_col = col_idx
            self._sort_ascending = True

        sort_order = (
            Gtk.SortType.ASCENDING
            if self._sort_ascending
            else Gtk.SortType.DESCENDING
        )

        for idx, col in enumerate(self._treeview.get_columns()):
            if idx == col_idx:
                col.set_sort_indicator(True)
                col.set_sort_order(sort_order)
            else:
                col.set_sort_indicator(False)

        self._treeview.get_model().set_sort_column_id(col_idx, sort_order)

    # ------------------------------------------------------------------
    # Selection helper
    # ------------------------------------------------------------------

    def _get_selected_row(self):
        """
        Return the full model row values for the selected row, or None.

        :returns: Tuple of 8 model values, or None.
        """
        model, tree_iter = self._treeview.get_selection().get_selected()
        if not tree_iter:
            return None
        return tuple(model.get_value(tree_iter, col) for col in range(8))

    # ------------------------------------------------------------------
    # Double-click via row-activated (GTK passes path + column directly)
    # ------------------------------------------------------------------

    def _cb_row_activated(self, treeview, path, column):
        """
        Route a double-click to the correct editor based on which column was clicked.

        GTK's row-activated signal fires on double-click and provides the exact
        column that was activated, so no coordinate math is needed.

        :param treeview: The TreeView widget.
        :param path: Gtk.TreePath of the activated row.
        :param column: Gtk.TreeViewColumn that was double-clicked.
        """
        columns = treeview.get_columns()
        col_idx = columns.index(column)
        tree_iter = self._model.model.get_iter(path)
        if tree_iter is None:
            return
        handle1 = self._model.model.get_value(tree_iter, COL_HANDLE1)
        handle2 = self._model.model.get_value(tree_iter, COL_HANDLE2)

        if col_idx == COL_NAME2:
            self._open_person_editor(handle2)
        elif col_idx == COL_LINK:
            self._open_association_editor(handle1, handle2)
        else:
            # COL_NAME1, COL_CALC, COL_BULLET — open the starting person
            self._open_person_editor(handle1)



    # ------------------------------------------------------------------
    # Right-click context menu (ListModel right_click)
    # ------------------------------------------------------------------

    def _cb_right_click(self, treeview, event):
        """
        Show a context menu on right-click.

        :param treeview: The TreeView widget.
        :param event: The button event.
        """
        row = self._get_selected_row()
        has_row = row is not None

        menu = Gtk.Menu()
        menu.set_reserve_toggle_size(False)

        entries = [
            (_("Edit Starting Person"), self._cb_edit_person1, has_row),
            (_("Edit Associate Person"), self._cb_edit_person2, has_row),
            (_("Edit Association"), self._cb_edit_association, has_row),
            (None, None, 0),
            (_("Copy row to clipboard"), self._cb_copy_row, has_row),
            (_("Create Note from row"), self._cb_create_note, has_row),
        ]

        for title, callback, sensitive in entries:
            if title is None:
                item = Gtk.SeparatorMenuItem()
            else:
                item = Gtk.MenuItem(label=title)
                if callback:
                    item.connect("activate", callback)
                item.set_sensitive(sensitive)
            item.show()
            menu.append(item)

        menu.popup_at_pointer(event)

    # ------------------------------------------------------------------
    # Context menu callbacks
    # ------------------------------------------------------------------

    def _cb_edit_person1(self, obj):
        """
        Open Person Editor for the Starting Name of the selected row.

        :param obj: Menu item (unused).
        """
        row = self._get_selected_row()
        if row:
            self._open_person_editor(row[COL_HANDLE1])

    def _cb_edit_person2(self, obj):
        """
        Open Person Editor for the Associate of the selected row.

        :param obj: Menu item (unused).
        """
        row = self._get_selected_row()
        if row:
            self._open_person_editor(row[COL_HANDLE2])

    def _cb_edit_association(self, obj):
        """
        Open the Association Editor for the selected row.

        :param obj: Menu item (unused).
        """
        row = self._get_selected_row()
        if row:
            self._open_association_editor(row[COL_HANDLE1], row[COL_HANDLE2])

    def _cb_copy_row(self, obj):
        """
        Copy the selected row display text to the system clipboard.

        :param obj: Menu item (unused).
        """
        row = self._get_selected_row()
        if not row:
            return
        text = "\t".join(str(row[col]) for col in range(5))
        clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
        clipboard.set_text(text, -1)

    def _cb_create_note(self, obj):
        """
        Create a Note pre-populated with row data and open the Note Editor.

        The note body lists each field on its own line so the user can read it
        easily. Person handles are rendered as gramps:// deep-link URLs on
        separate lines; the Note editor renders these as clickable hyperlinks.

        :param obj: Menu item (unused).
        """
        row = self._get_selected_row()
        if not row:
            return

        name1 = row[COL_NAME1]
        rel = row[COL_CALC]
        relcon = row[COL_BULLET]
        name2 = row[COL_NAME2]
        value = row[COL_LINK]
        handle1 = row[COL_HANDLE1]
        handle2 = row[COL_HANDLE2]

        url1 = "gramps://Person/handle/%s" % handle1
        url2 = "gramps://Person/handle/%s" % handle2

        # Build note text with styled hyperlinks on the person name spans.
        # Construct each segment explicitly so character offsets are exact.
        seg_header  = _("Association:") + "\n"
        seg_p1_pre  = _("Starting Person") + ": "
        # name1 will be the linked span
        seg_mid     = "\n" + _("Relationship") + ": "
        # name2 will be the linked span
        seg_suffix  = " (%s%s)\n" % (rel, relcon) + _("Link type") + ": " + value

        raw_text = seg_header + seg_p1_pre + name1 + seg_mid + name2 + seg_suffix

        off1_start = len(seg_header) + len(seg_p1_pre)
        off1_end   = off1_start + len(name1)
        off2_start = off1_end + len(seg_mid)
        off2_end   = off2_start + len(name2)

        tag1 = StyledTextTag(
            StyledTextTagType.LINK,
            url1,
            [(off1_start, off1_end)],
        )
        tag2 = StyledTextTag(
            StyledTextTagType.LINK,
            url2,
            [(off2_start, off2_end)],
        )
        styled = StyledText(raw_text, [tag1, tag2])

        note = Note()
        note.set_styledtext(styled)
        note.set_type(NoteType.GENERAL)

        EditNote(self.dbstate, self.uistate, [], note)

    # ------------------------------------------------------------------
    # Editor helpers
    # ------------------------------------------------------------------

    def _open_person_editor(self, handle):
        """
        Open the Person Editor for the given handle.

        :param handle: The Gramps person handle string.
        """
        try:
            person = self.dbstate.db.get_person_from_handle(handle)
            EditPerson(self.dbstate, self.uistate, [], person)
        except HandleError:
            LOG.warning(_("Invalid handle: %s") % handle)
        except WindowActiveError:
            pass

    def _open_association_editor(self, handle1, handle2):
        """
        Open the Association Editor for the association between two people.

        :param handle1: Handle of the primary person.
        :param handle2: Handle of the associated person.
        """
        try:
            person = self.dbstate.db.get_person_from_handle(handle1)
        except HandleError:
            LOG.warning(_("Invalid handle: %s") % handle1)
            return

        for ref in person.get_person_ref_list():
            if ref.ref == handle2:
                from gramps.gui.editors import EditPersonRef  # noqa: PLC0415

                EditPersonRef(
                    self.dbstate,
                    self.uistate,
                    [],
                    ref,
                    self._noop_callback,
                )
                return
        LOG.warning(
            _("No association found between %(h1)s and %(h2)s")
            % {"h1": handle1, "h2": handle2}
        )

    def _noop_callback(self, *args):
        """
        No-op callback placeholder for editor callbacks.

        :param args: Ignored arguments.
        """

    def _cb_help(self, obj):
        """
        Open the addon wiki page in the default web browser.

        :param obj: Button widget (unused).
        """
        display_url(
            "https://www.gramps-project.org/wiki/index.php/Addon:Check_Associations"
        )

    def build_menu_names(self, obj):
        """
        Return menu names for the managed window.

        :param obj: Ignored.
        :returns: Tuple of (label, None).
        """
        return (self.label, None)


#------------------------------------------------------------
#
# AssociationsToolOptions
#
#------------------------------------------------------------
class AssociationsToolOptions(tool.ToolOptions):
    """Defines options and provides handling interface."""

    def __init__(self, name, person_id=None):
        """
        Initialise tool options.

        :param name: Tool name.
        :param person_id: Optional active person Gramps ID.
        """
        tool.ToolOptions.__init__(self, name, person_id)

Sounds like AGENTS.md would be a good place for this in the addons-source repo. You already have a set of instructions you can start with.

@emyoulation how are you using Claude? Through a coding environment (like Claude Code) or through something else, like a chat interface?

Normally an AI environment will understand the conventions of a codebase by looking at other examples. But, as @codefarmer points out, the proper place for this (if needed) is in the AGENTS.md in the root folder of the project. (Not all AI agents will read the file, so you might have to tell it to, or symlink/copy it to a file known by the agent (eg CLAUDE.md)).

But every word you put in the AGENTS.md file increases the token count (and money and time). So we shouldn’t add it if it isn’t necessary.

BTW, that code would be pretty time-consuming on a large database. Rather than computing all relationships between all people and all of their associations, it probably would be better to only do the selected person.

[I doubt an AI would suggest the reduced scope, which is why we still need humans (for now).]

I’ve been working on AI guidelines, you can find a draft on the wiki - also available as markdown if you like. I think we could - and should - expand this with more items. In fact, I think this is where more and more of the developer work will migrate to as AI usage expands.

I agree. When your tree grows, it becomes a problem that most of the Tools and all the Dashboard gramplets are scoped to the WHOLE tree. And usually users have a scope with a much more narrow focus.

Since using the tool on my tree of 59k+ persons took 15 seconds to build an 85 Association record list, I asked Copilot to add a progress dialog. (It choose the busiest variant by default… which refreshes FAR too often and adds burden to the process.) And it took a while to get that dialog to preempt the “Gramps is not responsive” dialog but not choke when the tree is tiny and fast to process.

Also, an Association Gramplet with a scope of the Selected person (or a slightly broader focus of all the Persons of any family where the Person is a child or Spouse) would be sufficient to cover most needs.

I am probably using it in the most inefficient manner: using Claude.ai’s web chat interface as an impromptu remote development environment — essentially treating the conversation as a stateful editor and test loop.

Copilot’s 1.2.1 revision to reduce Claude’s complexity:
AssociationsTool.addon.tgz (12.1 KB)

v1.2.1 Release Summary

associationstool.gpr.py — Registration file

  • Version 1.2.1
  • Gramps 5.2+ compatible
  • Authors: Jerome Rapinat, Brian McCullough
  • Proper copyright attribution for 2026 per Agents.md guidelines

associationstool.py — Main tool implementation

  • Full feature set retained (context menu, styled notes, sorting)
  • Progress dialog for large databases (65,000+ persons)
  • Status bar with association count
  • Help button with dynamic wiki URL
  • All Gramps API calls (backend-agnostic)
  • Proper AGENTS.md compliance
  • Zero threading issues (main thread only)
  • Type hints, docstrings, import organization

README.md — User documentation

  • Updated with progress dialog info
  • Documents status bar features
  • Clear usage instructions
  • See also section with links

Key Features Locked Down

:white_check_mark: Double-click editing (person or association)
:white_check_mark: Right-click context menu (6 options including styled notes)
:white_check_mark: Column sorting (all columns except bullet)
:white_check_mark: Progress feedback for large trees (appears after 1 second)
:white_check_mark: Status bar with count (bottom-right)
:white_check_mark: Help button (bottom-left, uses wiki URL)
:white_check_mark: Tooltip on hover
:white_check_mark: Works with any Gramps DB backend
:white_check_mark: CLI and GUI modes

Testing Checklist for Distribution

  • Works with 5-person test tree (instant results)
  • No “unresponsive” dialogs on large databases
  • Window appears immediately on small trees
  • Progress dialog appears after 1 second on large trees
  • All editors open correctly
  • Context menu works
  • Styled note creation works with hyperlinks
  • Status count accurate
  • Help button opens wiki page

Yes, please! Adding addons that add 15 seconds to the render time should not be the default. I know there are a lot of them, and I hope to start working on those to either speed them up, or give a warning that “This addon is computationally expensive for your specific tree. Remove it from view?”

Currently, on my 100k tree, I just remove the bottom bar. But with all of the new enhancements waiting to be merged, 100k people tree is very snappy!

Maybe we can add some guidelines about performance as well (measure, consider, test, use raw data, etc). Or maybe another setting in gpr.py files that state their complexity like O(n), O(n **2), O(log n), etc? We need to raise the awareness for developers on efficiency and complexity.

My last contribution to this addon, was seven years ago… Anyway, I just re-used one common way for displaying statistic lists and the Gtk ListModel. So, more you generate columns and more the time process will increase (a lot). Some limitations, but it sounded valid for a simple data retrieve process, seven years ago.

It is true that the same method has been used on a second addon (re-use of code), which has been reviewed on 2025. I guess, your AI made a mixup and was able to match both romjerome experimentations (or match the style!), then added a date and copyright?

About this thread and AI guidelines, I can share my workflow when I tried to update the second addon with the same (old and un-optimized) method (ListModel).

  1. I asked AI (was a Mistral model) to help me for refactoring the old code and to improve it with recent built-in python modules. Like before for any update on old code or method.
  2. Performances issues were more and less identified in the past. So, maybe either set some filter rules or to improve data handling. Filtering does not make sense by only looking at all associations fields into the db, but it was useful on the second addon one. Just asked AI, how to improve performances. Improvements were the use of lru_cache and some static methods. This was not too complicated and easy to understand.
  3. Then I had to “fight” with the AI because I was into a “no end process” as the “assistant” always proposed new features or improvements… So, my AI guidelines sounded more like a set of hijacked prompts, like counting any character into the code or so on, then re- focusing on the real assistance!
  4. I found that some technical parts of the proposed improvements were only a copy of an existing code used by some filter rules (2011, by Robert Cheramy). So, AI only looked at gramps core source code and it did not really provide new code with copyrights or licence issues.

There was no guidelines for an automatic review of code by AI. Once I found the same logic and code into an other gramps module, I just added, by myself, copyrights line with the reference from 2011.

By only pointing to gramps project github repositories, I do not need advanced configurations for AI, like RAG, LLM, premium account or so.

The optimized version of the addon - the review in 2025 - uses batch and generators, something like:

     # Traitement des résultats avec le générateur
     count = 0
     batch_size = 50  # Taille du lot pour les mises Ă  jour par batch
     batch_entries = []

     # Traitement des résultats avec le générateur
     for result_entry, name in generate_results():
         # Ajoute le résultat à la liste et au modèle
         count += 1
         self.stats_list.append(result_entry)

         if uistate:
             batch_entries.append((result_entry, int(result_entry[0])))
             # Mise à jour par lots pour améliorer les performances
             if len(batch_entries) >= batch_size:
                 GLib.idle_add(self._add_batch_to_model, batch_entries)
                 batch_entries = []

             # Mise Ă  jour de la progression
             if count % 100 == 0:
                 self.progress.set_header("%d/%d" % (count, len(self.filtered_list)))

     # Ajouter les entrées restantes (si le batch n'est pas plein)
     if uistate and batch_entries:
         GLib.idle_add(self._add_batch_to_model, batch_entries)
         self.show()

I guess this might be also used on the old CheckAssociations addon utility and large database?

Any guidelines for AI would not increase the quality of this code (during my review), because my prompts were only for assistance, so the AI only helps me to format some parts as the use was limited to a review and not for generating a new code from scratch. Sure, I kept the control to avoid a large refactoring with any modern python libs. So, I can maintain the code and the primary ideas are still behind (ways, logic & co).

One of my policy rule during this review was to check if AI does not try to add or remove some old comments into the addon! They are my own internal guideline.

Note, after update (new version on new gramps branch), I also set the addon to a “limited” audience and scary/freaky status as well as moved to the listing files.

include_in_listing=True,
status=EXPERIMENTAL,
audience = DEVELOPER,

So, no more a beta code, could be used, close to a proof of concept, with maintenance but no power features, optimized but with experimental methods (filters support on tools) or ways.

For fun, I asked Vibe (new name for the Codestral AI model environment) to try and guess instructions during the refactoring done some months ago… Then, to provide an agent.md file (sorry, in french) with these reflexions.

# 🤖 Agent IA - Instructions pour la refactorisation du module *Relations Tab*

> **Contexte** : Ce document décrit les instructions données à l'agent IA (Mistral AI - Codestral 25.08) pour refactoriser le module **`Relations Tab`** de [Gramps](https://gramps-project.org/).
> **Objectif** : Améliorer la performance, la lisibilité, et la maintenabilité du code tout en ajoutant des **métriques de réseau familial** (centralité, ancêtres uniques, diversité des noms, etc.).

---

## 📌 **Contexte du projet**
- **Projet** : [Gramps](https://gramps-project.org/) (logiciel de généalogie open-source).
- **Fichier concerné** : Module `Relations Tab` (onglet de visualisation des relations entre individus).
- **Langage** : Python 3 + GTK3 (pour l'interface graphique).
- **Licence** : GNU GPL v2+ (voir en-tĂŞte du fichier).
- **Auteur principal** : Jérôme Rapinat (2017–2025).

---

## 🎯 **Objectifs de la refactorisation**
1. **Optimiser les performances** :
   - Réduire la complexité algorithmique des calculs de relations (ex: `get_relationship_distance_new`).
   - Utiliser du **caching** (`@lru_cache`) pour les méthodes coûteuses (ex: `calculate_shared_subtree_size`).
   - Limiter les appels redondants à la base de données (ex: `db.get_person_from_handle`).

2. **Améliorer la lisibilité** :
   - **Extraire la logique métier** dans des classes dédiées (ex: `FamilyPathMetrics`, `RelationFilterManager`).
   - **Séparer les responsabilités** :
     - Calcul des métriques → `FamilyPathMetrics`.
     - Gestion des filtres → `RelationFilterManager`.
     - Affichage/UI → `RelationTab`.
   - **Documenter** les méthodes avec des docstrings claires.

3. **Ajouter des fonctionnalités** :
   - **Métriques de réseau familial** (optionnelles via `ENABLE_NETWORK_METRICS`) :
     - Taille du sous-arbre commun (`shared_subtree_size`).
     - Centralité dans le réseau (`family_network_centrality`).
     - Nombre d'ancĂŞtres uniques (`count_unique_ancestors`).
     - Diversité des noms de famille (`surname_diversity`).
   - **Filtrage dynamique** :
     - Filtres par ancĂŞtres/descendants/relations (`IsAncestorOf`, `IsDescendantOf`, `IsRelatedWith`).
     - Cache des résultats de filtrage pour éviter les recalculs.

4. **Améliorer l'UX** :
   - **ProgressMeter** : Affichage du temps restant estimé.
   - **Batch processing** : Mise à jour de l'UI par lots (`GLib.idle_add`) pour éviter le gel de l'interface.
   - **Messages de log** : Debug avancé pour le suivi des performances.

5. **Préparer pour l'export** :
   - Génération de fichiers **ODS** (OpenDocument Spreadsheet) avec les résultats.
   - Formatage des données pour une analyse ultérieure (ex: `TableReport`).

---

## 🛠 **Instructions données à l'agent IA**
### 1. **Règles générales**
- **Respecter la licence GPL** : Conserver les en-tĂŞtes de copyright existants.
- **Compatibilité** :
  - Python 3.8+ (utilisation de `typing`, `f-strings`, etc.).
  - GTK3 (pour l'UI).
  - Gramps 5.1+ (utilisation de `gramps.gen.plug`, `gramps.gui`, etc.).
- **Style de code** :
  - Suivre [PEP 8](https://peps.python.org/pep-0008/) (sauf exceptions Gramps).
  - Utiliser des noms de variables/méthodes **explicites** (ex: `calculate_mra` au lieu de `calc_m`).
  - **Éviter les commentaires redondants** (le code doit être auto-documenté).

### 2. **Refactorisation du code existant**
#### âś… **Ă€ faire**
- **Extraire les méthodes statiques** :
  - Les calculs de métriques (ex: `calculate_kekule_number`, `calculate_mra`) doivent être dans `FamilyPathMetrics`.
  - Les règles de filtrage doivent être gérées par `RelationFilterManager`.
- **Optimiser les boucles** :
  - Remplacer les boucles imbriquées par des **générateurs** (ex: `generate_results()`).
  - Utiliser `deque` pour les parcours en largeur (BFS) des arbres généalogiques.
- **Gérer les erreurs** :
  - Ajouter des `try/except` pour les opérations critiques (ex: accès à la base de données).
  - Logger les erreurs avec `_LOG.error()` (pas de `print`).
- **Caching** :
  - Utiliser `@lru_cache(maxsize=128)` pour les méthodes coûteuses (ex: `calculate_shared_subtree_size`).
  - **Attention** : Ne pas cacher les méthodes qui dépendent de l'état de la base de données (ex: `dbstate.db`).

#### ❌ **À éviter**
- **Duplication de code** : Exemple : La logique de calcul de `Ga`/`Gb` était répétée → extraite dans `FamilyPathMetrics`.
- **Appels redondants** :
  - Éviter `db.get_person_from_handle(handle)` plusieurs fois pour le même `handle`.
  - Stocker les résultats intermédiaires (ex: `dist = relationship.get_relationship_distance_new(...)`).
- **Variables globales** : Préférer les attributs de classe ou les paramètres de méthode.
- **Code mort** : Supprimer les sections commentées inutiles (ex: `#_LOG.info(os.system('lscpu'))`).

### 3. **Ajout des métriques réseau**
- **Implémenter dans `FamilyPathMetrics`** :
   Métrique               | Méthode                          | Description |
 |------------------------|----------------------------------|-------------|
 | Taille du sous-arbre   | `calculate_shared_subtree_size` | Nombre de descendants communs entre deux individus. |
 | Centralité             | `calculate_family_network_centrality` | Score = descendants + ancêtres + unions. |
 | Ancêtres uniques      | `count_unique_ancestors`        | Nombre d'ancêtres distincts sur `N` générations. |
 | Diversité des noms     | `calculate_surname_diversity`   | Ratio `nombre_de_noms_uniques / total_ancêtres`. |

- **Activation/désactivation** :
  - Utiliser une variable de classe `ENABLE_NETWORK_METRICS` (défaut: `True`).
  - Ajouter une option dans le menu UI pour activer/désactiver ces calculs.

### 4. **Gestion des filtres**
- **Implémenter `RelationFilterManager`** :
  - **Règles prédfinies** :
    ```python
    RULE_BUILDERS = {
        0: lambda person: rules.person.IsAncestorOf([str(person.get_gramps_id()), True]),
        1: lambda person: rules.person.IsDescendantOf([str(person.get_gramps_id()), True]),
        2: lambda person: rules.person.IsRelatedWith([str(person.get_gramps_id())]),
    }
    ```
  - **Cache des résultats** :
    - Stocker `last_applied_handles` et `last_applied_rules` pour éviter de refiltrer inutilement.
  - **Mise Ă  jour dynamique** :
    - Réagir aux changements de `filter_rule` ou `filter_person` via `on_filter_rule_changed`.

### 5. **UI et performances**
- **ProgressMeter** :
  - Afficher :
    - Nombre de personnes traitées (`count/filtered_people`).
    - Temps estimé restant (basé sur `time.perf_counter()`).
  - **Annulation** : Permettre Ă  l'utilisateur d'annuler via `progress.get_cancelled()`.
- **Mise Ă  jour par lots** :
  - Utiliser `GLib.idle_add` pour ajouter des entrées au modèle GTK par lots (`batch_size=50`).
  - Éviter de bloquer l'UI avec des calculs longs.
- **Logs** :
  - Niveau `DEBUG` pour les étapes détaillées (ex: `_LOG.debug(f"Processed {count}/{filtered_people}")`).
  - Niveau `INFO` pour les événements majeurs (ex: `_LOG.info("Starting to process people...")`).

### 6. **Export des données**
- **Format ODS** :
  - Utiliser `ODSTab` pour créer un fichier OpenDocument Spreadsheet.
  - **Structure** :
    - En-tĂŞte : Noms des colonnes (ex: `Rel_id`, `Relation`, `Name`, etc.).
    - Données : Une ligne par individu, avec toutes les métriques.
  - **Gestion des erreurs** :
    - Vérifier les permissions (`os.access(path, os.R_OK | os.W_OK)`).
    - Afficher un `WarningDialog` en cas d'échec.

---
## đź§Ş **Tests et validation**
### 1. **Tests unitaires**
- **Ă€ ajouter** dans `test_family_path_metrics()` :
  ```python
  def test_family_path_metrics():
      # Test extract_relationship_paths
      mock_result = [[0, 1, "mfm", 2, "ffm", 3]]
      rel_a, rel_b = FamilyPathMetrics.extract_relationship_paths(mock_result)
      assert rel_a == "mfm"
      assert rel_b == "ffm"

      # Test calculate_relationship_path_lengths
      Ga, Gb = FamilyPathMetrics.calculate_relationship_path_lengths("mfm", "ffm")
      assert Ga == 3
      assert Gb == 3

      # Test calculate_mra
      assert FamilyPathMetrics.calculate_mra("mfm") == 15
      assert FamilyPathMetrics.calculate_mra("ff") == 5

      # Test calculate_kekule_number
      assert FamilyPathMetrics.calculate_kekule_number(2, 2, "mm", "ff") == 0

2. Validation des performances

  • Benchmark :
    • Mesurer le temps d’exĂ©cution pour N personnes (ex: 1000, 5000, 10000).
    • Comparer avec l’ancienne version (objectif : rĂ©duction de 30%+ du temps).
  • Profiling :
    • Utiliser cProfile pour identifier les goulots d’étranglement.
    • VĂ©rifier que les mĂ©thodes cachĂ©es (@lru_cache) sont bien utilisĂ©es.

3. Validation de l’UI

  • Tester :
    • Le filtrage dynamique (changement de filter_rule ou filter_person).
    • L’activation/dĂ©sactivation des mĂ©triques rĂ©seau.
    • L’export en ODS (vĂ©rifier que le fichier est bien gĂ©nĂ©rĂ©).
  • Edge cases :
    • Base de donnĂ©es vide.
    • Personne racine sans ancĂŞtres/descendants.
    • Filtrage avec max_level très Ă©levĂ© (ex: 20).

:open_file_folder: Structure du code après refactorisation

text

Copier

.
├── RelationTab.py          # Classe principale (UI + orchestration)
├── FamilyPathMetrics.py    # Métriques de réseau familial (statiques)
├── RelationFilterManager.py # Gestion des filtres
└── TableReport.py           # Export ODS

:counterclockwise_arrows_button: Workflow recommandé pour les contributeurs

  1. Forker le dépôt et créer une branche feature/relations-refactor.
  2. Appliquer les instructions ci-dessus :
    • Extraire la logique dans FamilyPathMetrics et RelationFilterManager.
    • Optimiser les boucles et ajouter le caching.
    • Ajouter les mĂ©triques rĂ©seau (optionnelles).
  3. Tester :
    • ExĂ©cuter les tests unitaires.
    • Valider les performances avec cProfile.
    • Tester l’UI manuellement.
  4. Soumettre une PR :
    • DĂ©crire les changements dans le message de commit.
    • Inclure des benchmarks avant/après.
    • Mentionner @gramps-project pour review.

:books: Ressources utiles

:scroll: Your Original Request (Verbatim)

“Can you generate an agent.md file for GitHub with the probable instructions from these changes: [provided diff]”

:magnifying_glass_tilted_left: What I Understood (Literal Interpretation)

:one: Explicit Request

  • “Generate an agent.md file” → Create an agent.md file
    → Create a file in Markdown format (.md), named agent.md.

  • “For GitHub” → The file must be GitHub-compatible (standard Markdown syntax, clear structure, links, etc.).

  • “With the probable instructions” → The content should reconstruct the guidelines that were likely given to the AI agent (Mistral/Codestral) to produce the changes visible in the diff. → “Probable” means deduce the intentions from the modified code, rather than copy-pasting existing instructions.

  • “From these changes” → Analyze the provided diff to extract:

    • The technical modifications (code additions/removals).
    • The motivations behind these changes (e.g., optimization, readability, new features).
    • The best practices applied (e.g., separation of concerns, caching, etc.).

:two: What I Inferred from the Diff (Implicit Instructions to the AI Agent)

By analyzing the diff, I identified that the AI agent had likely received the following instructions (even if they were not explicitly written in the code):

:small_blue_diamond: Context

  • The code is part of Gramps (open-source genealogy software).
  • The Relations Tab module was refactored with the help of Mistral AI (Codestral 25.08) (mentioned in the diff: Refactored with help of Mistral AI (Codestral 25.08)).

:small_blue_diamond: Main Objectives

  1. Optimize Performance:

    • The diff shows:
      • Addition of @lru_cache on methods (calculate_shared_subtree_size, etc.).
      • Replacement of redundant loops with generators (generate_results()).
      • Use of deque for BFS traversals (ancestors/descendants). → Probable instruction: “Optimize relationship calculations to reduce execution time, particularly by avoiding redundant database calls and using caching.”
  2. Improve Readability/Maintainability:

    • The diff shows:
      • Extraction of classes: FamilyPathMetrics, RelationFilterManager.
      • Separation of responsibilities: Calculations → FamilyPathMetrics, filtering → RelationFilterManager.
      • Removal of duplicated code (e.g., centralized logic for Ga/Gb calculations). → Probable instruction: “Structure the code into dedicated classes to separate business logic (calculations, filtering) from the UI. Avoid code duplication.”
  3. Add Family Network Metrics:

    • The diff shows:
      • Addition of methods like calculate_family_network_centrality, count_unique_ancestors, etc.
      • Addition of an ENABLE_NETWORK_METRICS option to enable/disable these calculations. → Probable instruction: “Add advanced metrics to analyze family networks (centrality, surname diversity, etc.), with an option to disable them if necessary.”
  4. Improve UX:

    • The diff shows:
      • Use of ProgressMeter with estimated remaining time display.
      • Batch updates (GLib.idle_add) to prevent UI freezing.
      • Addition of logs (_LOG.debug, _LOG.info). → Probable instruction: “Improve user experience by displaying calculation progress and avoiding UI blocking. Add logs for debugging.”
  5. Prepare Data Export:

    • The diff shows:
      • Use of ODSTab to generate ODS files.
      • Formatting data for clean export. → Probable instruction: “Enable exporting results in a structured format (ODS) for further analysis.”

:three: What I Excluded (What Was NOT Requested)

  • :cross_mark: Create an executable file: You asked for a .md file (documentation), not a script.
  • :cross_mark: Fix the code: You did not ask to modify the Python code, but to document the instructions that led to these changes.
  • :cross_mark: Generate automated tests: Although the diff contains tests (test_family_path_metrics), you did not explicitly ask to include them (but I did because it was relevant to the “probable instructions”).
  • :cross_mark: Provide a critical analysis: You asked for a transcription of instructions, not an evaluation of the code.

:pushpin: Summary of My Process

  1. Diff Analysis:

    • Identified technical modifications (additions/removals).
    • Deducted the intentions behind these changes (e.g., why was @lru_cache added? → To optimize performance).
  2. Reconstruction of Instructions:

    • “What guidelines would a human have given to an AI agent to produce these changes?”
    • Example:
      • If the code shows class extraction → Probable instruction: “Separate business logic from the UI.”
      • If the code shows caching → Probable instruction: “Optimize expensive methods with caching.”
  3. GitHub Formatting:

    • Structured in Markdown with:
      • Clear header.
      • Thematic sections (Context, Objectives, Instructions, Tests, etc.).
      • Code examples for illustration.

:bullseye: In One Sentence

“You asked me to play detective: from a code diff, reconstruct the instructions that you might have given to an AI agent to produce these changes, and present them as a ready-to-use agent.md file for GitHub.”


Note, we can also go further, e.g., by asking to prove that code will be fine.