diff --git a/CHANGELOG.md b/CHANGELOG.md index f4f27a0..cb617c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ### Added +- Projection contrôlée des champs OCR confirmés vers les attributs structurés + d’une personne, avec provenance append-only, conflits explicites et rollback. - Saisie et consultation, depuis la fiche preuve, d’un historique immuable d’appréciations humaines d’authenticité documentaire avec justification et `OcrRun` facultatif appartenant à la preuve, sans verdict automatique. diff --git a/Makefile b/Makefile index 7dda9d2..1de40e6 100644 --- a/Makefile +++ b/Makefile @@ -198,6 +198,8 @@ TEST_WORKSPACE_IDENTITY_OCR_GTK := tests/test_workspace_identity_ocr_gtk TEST_DIALOG_GEOMETRY_GTK := tests/test_dialog_geometry_gtk TEST_PERSON_FACTUAL_RELATION_EDITOR_GTK := tests/test_person_factual_relation_editor_gtk TEST_DOCUMENT_AUTHENTICITY_EDITOR_GTK := tests/test_document_authenticity_editor_gtk +TEST_PERSON_OCR_PROJECTION := tests/test_person_ocr_projection +TEST_PERSON_OCR_PROJECTION_EDITOR_GTK := tests/test_person_ocr_projection_editor_gtk FAKE_DOCUMENT_TOOL := tests/fake_document_tool DOCUMENT_ANALYSIS_TEST_SOURCES := \ @@ -236,6 +238,21 @@ $(TEST_DOCUMENT_AUTHENTICITY_EDITOR_GTK): \ src/database/error.c src/core/relation_type_normalizer.c $(CC) $(CFLAGS) $^ -o $@ $(LDFLAGS) +PERSON_OCR_PROJECTION_SOURCES := src/models/person_ocr_projection.c \ + src/core/person_ocr_projection_mapping.c src/core/person_ocr_projection_service.c \ + src/dao/person_ocr_projection_dao.c src/dao/identity_ocr_dao.c \ + src/models/identity_ocr.c src/models/identity_traceability.c \ + src/dao/entity_dao.c src/models/entity_record.c src/database/database.c \ + src/database/schema.c src/database/statement.c src/database/transaction.c \ + src/database/error.c src/core/relation_type_normalizer.c +$(TEST_PERSON_OCR_PROJECTION): tests/test_person_ocr_projection.c $(PERSON_OCR_PROJECTION_SOURCES) + $(CC) $(CFLAGS) $^ -o $@ $(LDFLAGS) +$(TEST_PERSON_OCR_PROJECTION_EDITOR_GTK): tests/test_person_ocr_projection_editor_gtk.c \ + src/views/person_ocr_projection_editor.c src/models/person_ocr_projection.c \ + src/core/person_ocr_projection_mapping.c src/models/identity_ocr.c \ + src/models/identity_traceability.c + $(CC) $(CFLAGS) $^ -o $@ $(LDFLAGS) + all: $(TARGET) $(TEST_IDENTITY_OCR): tests/test_identity_ocr.c \ @@ -958,6 +975,9 @@ $(TEST_CREATE_PERSON_DIALOG_GTK): tests/test_create_person_dialog_gtk.c \ src/views/create_person_dialog.c src/views/dialog_geometry.c \ src/views/person_vocabulary_adapter.c \ src/views/person_factual_relation_editor.c \ + src/views/person_ocr_projection_editor.c src/views/person_creation_confirmation.c \ + src/models/person_ocr_projection.c \ + src/core/person_ocr_projection_mapping.c \ src/views/identity_ocr_option_adapter.c \ src/core/person_dialog_lifecycle.c \ src/core/person_confirmation_summary.c \ @@ -989,6 +1009,7 @@ $(TEST_CREATE_PERSON_DIALOG_OCR_GTK): \ src/views/create_person_dialog.c src/views/dialog_geometry.c \ src/views/person_vocabulary_adapter.c \ src/views/person_factual_relation_editor.c \ + src/views/person_ocr_projection_editor.c src/views/person_creation_confirmation.c \ src/views/identity_ocr_option_adapter.c \ src/core/person_dialog_lifecycle.c \ src/core/person_confirmation_summary.c \ @@ -1000,6 +1021,8 @@ $(TEST_CREATE_PERSON_DIALOG_OCR_GTK): \ src/core/identity_ocr_workflow.c \ src/core/identity_field_extractor.c src/core/ocr_analysis.c \ src/core/person_creation_coordinator.c \ + src/models/person_ocr_projection.c src/core/person_ocr_projection_mapping.c \ + src/core/person_ocr_projection_service.c src/dao/person_ocr_projection_dao.c \ src/core/document_analysis.c src/core/document_tool_runner.c \ src/core/tool_registry.c src/models/identity_ocr.c \ src/dao/identity_ocr_dao.c src/dao/entity_dao.c \ @@ -1056,6 +1079,8 @@ $(TEST_EVIDENCE_STAGING): tests/test_evidence_staging.c \ $(TEST_PERSON_CREATION_COORDINATOR): \ tests/test_person_creation_coordinator.c \ src/core/person_creation_coordinator.c src/core/evidence_staging.c \ + src/models/person_ocr_projection.c src/core/person_ocr_projection_mapping.c \ + src/core/person_ocr_projection_service.c src/dao/person_ocr_projection_dao.c \ src/core/file_hash.c \ src/models/identity_ocr.c src/dao/identity_ocr_dao.c \ src/models/identity_traceability.c src/dao/identity_traceability_dao.c \ @@ -1212,7 +1237,9 @@ test: \ $(TEST_WORKSPACE_IDENTITY_OCR_GTK) \ $(TEST_DIALOG_GEOMETRY_GTK) \ $(TEST_PERSON_FACTUAL_RELATION_EDITOR_GTK) \ - $(TEST_DOCUMENT_AUTHENTICITY_EDITOR_GTK) + $(TEST_DOCUMENT_AUTHENTICITY_EDITOR_GTK) \ + $(TEST_PERSON_OCR_PROJECTION) \ + $(TEST_PERSON_OCR_PROJECTION_EDITOR_GTK) @echo "Exécution des tests..." @./$(TEST_NODE) @./$(TEST_TREE_MODEL) @@ -1310,6 +1337,8 @@ test: \ @$(TEST_DIALOG_GEOMETRY_GTK) @$(TEST_PERSON_FACTUAL_RELATION_EDITOR_GTK) @$(TEST_DOCUMENT_AUTHENTICITY_EDITOR_GTK) + @$(TEST_PERSON_OCR_PROJECTION) + @$(TEST_PERSON_OCR_PROJECTION_EDITOR_GTK) @echo "Tous les tests sont valides." %.o: %.c @@ -1411,6 +1440,8 @@ clean: $(TEST_DIALOG_GEOMETRY_GTK) \ $(TEST_PERSON_FACTUAL_RELATION_EDITOR_GTK) \ $(TEST_DOCUMENT_AUTHENTICITY_EDITOR_GTK) \ + $(TEST_PERSON_OCR_PROJECTION) \ + $(TEST_PERSON_OCR_PROJECTION_EDITOR_GTK) \ $(FAKE_DOCUMENT_TOOL) diff --git a/README.md b/README.md index b2494ab..7a0b5a7 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,10 @@ Le projet vise à fournir un environnement local, modulaire et traçable pour or La fiche d’une preuve permet aussi de consigner une appréciation humaine d’authenticité documentaire. Cet historique est immuable, justifié selon le statut choisi et ne peut jamais être produit automatiquement par l’OCR. +Les seules valeurs OCR pouvant alimenter un profil de personne sont celles +confirmées par un humain, sélectionnées sans choix par défaut puis validées +avec une cible et une stratégie de conflit explicites. +La fiche personne relit ensuite ces champs structurés depuis SQLite. > **État du projet : développement actif** > diff --git a/database/schema_v19.sql b/database/schema_v19.sql new file mode 100644 index 0000000..b48d680 --- /dev/null +++ b/database/schema_v19.sql @@ -0,0 +1,32 @@ +/* V19 — attributs structurés et provenance des projections OCR humaines. */ +CREATE TABLE person_profile_fields( + person_id TEXT NOT NULL,field_code TEXT NOT NULL,value TEXT NOT NULL, + updated_at TEXT NOT NULL CHECK(length(updated_at)=20), + PRIMARY KEY(person_id,field_code), + CHECK(field_code IN('declared_name','surname','given_names','birth_date', + 'birth_place','nationality','sex_as_printed','address_as_printed')), + CHECK(length(trim(value))>0), + FOREIGN KEY(person_id) REFERENCES entites(id) ON DELETE CASCADE); +CREATE TABLE person_ocr_field_projections( + id TEXT PRIMARY KEY,person_id TEXT NOT NULL,person_field_code TEXT NOT NULL, + previous_value TEXT,new_value TEXT NOT NULL,evidence_id TEXT NOT NULL, + ocr_run_id TEXT NOT NULL,ocr_field_id TEXT NOT NULL,ocr_field_code TEXT NOT NULL, + value_quality TEXT NOT NULL CHECK(value_quality IN('complete','partial')), + review_status TEXT NOT NULL CHECK(review_status IN('accepted','modified')), + strategy TEXT NOT NULL CHECK(strategy IN('fill_empty','replace_existing')), + projected_at TEXT NOT NULL CHECK(length(projected_at)=20), + origin TEXT NOT NULL CHECK(origin='human'), + FOREIGN KEY(person_id) REFERENCES entites(id) ON DELETE RESTRICT, + FOREIGN KEY(evidence_id) REFERENCES preuves(id) ON DELETE RESTRICT, + FOREIGN KEY(ocr_run_id) REFERENCES identity_ocr_runs(id) ON DELETE RESTRICT, + FOREIGN KEY(ocr_field_id) REFERENCES identity_field_observations(id) ON DELETE RESTRICT); +CREATE INDEX idx_person_projection_person ON person_ocr_field_projections(person_id,projected_at,id); +CREATE TRIGGER projection_v19_consistency BEFORE INSERT ON person_ocr_field_projections BEGIN + SELECT CASE WHEN NOT EXISTS(SELECT 1 FROM identity_field_observations f + WHERE f.id=NEW.ocr_field_id AND f.ocr_run_id=NEW.ocr_run_id + AND f.evidence_id=NEW.evidence_id AND f.field_code=NEW.ocr_field_code + AND f.confirmation_state='human_confirmed' AND length(trim(f.confirmed_value))>0 + AND f.review_status IN('accepted','modified') + AND f.value_quality IN('complete','partial') AND f.confirmed_value=NEW.new_value) + THEN RAISE(ABORT,'OCR field is no longer projectable') END; +END; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 36aa69b..b661447 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -128,6 +128,12 @@ l’authenticité à `DocumentAuthenticityEditor`, qui appelle SQLite. Le service ajoute chaque appréciation humaine dans une transaction et chaîne son identifiant à l’appréciation courante. +La V19 ajoute les champs de profil structurés et l’historique des projections +OCR humaines. `PersonOcrProjectionEditor` ne sélectionne rien par défaut ; le +service relit le champ V18 avant COMMIT et conserve la valeur existante par +défaut en cas de conflit. La fiche personne relit ces champs par +`PersonDetailsProvider` et le DAO de projection. + --- ## 1. Objectif diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index b33757d..4d24b9d 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -20,6 +20,11 @@ L’authenticité documentaire se valide avec sur un affichage GTK réel avec `G_DEBUG=fatal-criticals`; ses données et sa base SQLite sont exclusivement temporaires et marquées `SPECIMEN`. +La projection OCR se valide avec `test_person_ocr_projection`, +`test_person_creation_coordinator` et +`test_person_ocr_projection_editor_gtk`, ce dernier sous affichage réel et +`G_DEBUG=fatal-criticals`. + La validation ciblée de l’aperçu partagé comprend `test_evidence_preview_widget_gtk`, `test_evidence_preview`, `test_evidence_video_preview_controller` et diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1183f70..2abc69c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -180,6 +180,8 @@ PARTIEL — VALIDATION MANUELLE DES TRANCHES LIVRÉES RÉUSSIE valeur explicitement confirmée et contraintes anti-automatisme. - interface GTK de saisie et de consultation de l’authenticité documentaire depuis la fiche preuve, avec OcrRun facultatif filtré et historique immuable. +- projection humaine explicite des seules valeurs OCR V18 confirmées vers les + champs structurés de la personne, avec provenance et rollback V19. Limitations restantes : @@ -187,9 +189,7 @@ Limitations restantes : charge ; 2. les interfaces GTK complètes des états d’identification et des rôles V18 restent à construire ; -3. la provenance uniforme de tous les dérivés et la projection des seules - valeurs OCR confirmées vers les attributs structurés de la personne restent - partielles ; +3. la provenance uniforme de tous les autres dérivés reste partielle ; Ces limites interdisent de présenter le ticket #109 comme terminé. diff --git a/docs/database/DATABASE_ARCHITECTURE.md b/docs/database/DATABASE_ARCHITECTURE.md index fd92216..c12b2fb 100644 --- a/docs/database/DATABASE_ARCHITECTURE.md +++ b/docs/database/DATABASE_ARCHITECTURE.md @@ -21,8 +21,13 @@ appartient à la même preuve. `previous_assessment_id` forme une chaîne append-only ; l’ajout et la détermination du maillon courant sont coordonnés dans une transaction. +V19 ajoute `person_profile_fields` et `person_ocr_field_projections`. Cette +dernière table conserve personne, cible, valeurs précédente et nouvelle, +preuve, OcrRun, champ OCR, qualité, stratégie, date et origine humaine. Un +trigger refuse une source devenue non projectable. + > **Statut :** architecture courante -> **Version du schéma :** V18 +> **Version du schéma :** V19 > **Dernière mise à jour :** 2026-07-24 > **Source de vérité détaillée :** `SCHEMA_AUDIT_CURRENT.md` diff --git a/include/core/person_creation_coordinator.h b/include/core/person_creation_coordinator.h index 5994005..9e80070 100644 --- a/include/core/person_creation_coordinator.h +++ b/include/core/person_creation_coordinator.h @@ -5,6 +5,7 @@ #include "models/person_evidence_selection.h" #include "models/identity_ocr.h" #include "models/identity_traceability.h" +#include "models/person_ocr_projection.h" #include G_BEGIN_DECLS @@ -33,6 +34,7 @@ typedef enum { PERSON_CREATION_FAILURE_INSERT_FIELD, PERSON_CREATION_FAILURE_CREATE_SOURCE, PERSON_CREATION_FAILURE_INSERT_FACTUAL_RELATION, + PERSON_CREATION_FAILURE_APPLY_OCR_PROJECTION, PERSON_CREATION_FAILURE_SESSION_BEFORE_COMMIT, PERSON_CREATION_FAILURE_ARTIFACT_TEXT_CHANGED, PERSON_CREATION_FAILURE_ARTIFACT_TSV_CHANGED, @@ -56,6 +58,8 @@ typedef struct { gpointer session_check_data; /** PersonCreationFactualRelationInput* empruntés, choix humains explicites. */ const GPtrArray *factual_relations; + /** PersonOcrFieldProjection* empruntées, toutes choisies explicitement. */ + const GPtrArray *ocr_projections; } PersonCreationCoordinatorOptions; typedef struct { const char *collected_at; diff --git a/include/core/person_creation_task.h b/include/core/person_creation_task.h index 665c0e4..da89114 100644 --- a/include/core/person_creation_task.h +++ b/include/core/person_creation_task.h @@ -11,7 +11,7 @@ PersonCreationTaskRequest *person_creation_task_request_new( const char *database_path, const char *investigation_root_path, const PersonEntityInput *person, const PersonEvidenceSelection *selection, const GPtrArray *ocr_runs, - const GPtrArray *factual_relations); + const GPtrArray *factual_relations,const GPtrArray *ocr_projections); void person_creation_task_request_free(PersonCreationTaskRequest *request); BackgroundTask *person_creation_task_start(TaskManager *manager, const PersonCreationTaskRequest *request, diff --git a/include/core/person_details_provider.h b/include/core/person_details_provider.h index 8c239f4..4f3edec 100644 --- a/include/core/person_details_provider.h +++ b/include/core/person_details_provider.h @@ -6,5 +6,6 @@ GPtrArray *person_details_provider_get_evidences(Database *database, const char *person_identifier, GError **error); GPtrArray *person_details_provider_get_factual_relations(Database *database, const char *person_identifier, GError **error); +GHashTable *person_details_provider_get_profile_fields(Database *database, const char *person_identifier, GError **error); #endif diff --git a/include/core/person_ocr_projection_mapping.h b/include/core/person_ocr_projection_mapping.h new file mode 100644 index 0000000..545f277 --- /dev/null +++ b/include/core/person_ocr_projection_mapping.h @@ -0,0 +1,9 @@ +#ifndef LABFY_PERSON_OCR_PROJECTION_MAPPING_H +#define LABFY_PERSON_OCR_PROJECTION_MAPPING_H +#include +G_BEGIN_DECLS +typedef struct{const char*ocr_code;const char*person_field;const char*label;}PersonOcrProjectionMapping; +const PersonOcrProjectionMapping *person_ocr_projection_mapping_for(const char *ocr_code); +gboolean person_ocr_projection_mapping_is_compatible(const char *ocr_code,const char *person_field); +G_END_DECLS +#endif diff --git a/include/core/person_ocr_projection_service.h b/include/core/person_ocr_projection_service.h new file mode 100644 index 0000000..0c0342c --- /dev/null +++ b/include/core/person_ocr_projection_service.h @@ -0,0 +1,10 @@ +#ifndef LABFY_PERSON_OCR_PROJECTION_SERVICE_H +#define LABFY_PERSON_OCR_PROJECTION_SERVICE_H +#include "database/database.h" +#include "models/person_ocr_projection.h" +#include +G_BEGIN_DECLS +GPtrArray *person_ocr_projection_service_candidates(Database *database,const char *evidence_id,const char *run_id,GError **error); +gboolean person_ocr_projection_service_apply(Database *database,const char *person_id,const GPtrArray *projections,GError **error); +G_END_DECLS +#endif diff --git a/include/dao/person_ocr_projection_dao.h b/include/dao/person_ocr_projection_dao.h new file mode 100644 index 0000000..d63bf6d --- /dev/null +++ b/include/dao/person_ocr_projection_dao.h @@ -0,0 +1,15 @@ +#ifndef LABFY_PERSON_OCR_PROJECTION_DAO_H +#define LABFY_PERSON_OCR_PROJECTION_DAO_H +#include "database/database.h" +#include "models/person_ocr_projection.h" +#include +G_BEGIN_DECLS +typedef struct PersonOcrProjectionDao PersonOcrProjectionDao; +PersonOcrProjectionDao *person_ocr_projection_dao_new(Database *database); +void person_ocr_projection_dao_free(PersonOcrProjectionDao *dao); +char *person_ocr_projection_dao_get_value(PersonOcrProjectionDao *dao,const char *person_id,const char *field,GError **error); +gboolean person_ocr_projection_dao_apply(PersonOcrProjectionDao *dao,const char *person_id,const PersonOcrFieldProjection *projection,const char *timestamp,GError **error); +GPtrArray *person_ocr_projection_dao_list(PersonOcrProjectionDao *dao,const char *person_id,GError **error); +GHashTable *person_ocr_projection_dao_list_profile_fields(PersonOcrProjectionDao *dao,const char *person_id,GError **error); +G_END_DECLS +#endif diff --git a/include/database/schema.h b/include/database/schema.h index 49f7ce3..ace0a5f 100644 --- a/include/database/schema.h +++ b/include/database/schema.h @@ -99,6 +99,7 @@ bool schema_install_v15(Database *database); bool schema_install_v16(Database *database); bool schema_install_v17(Database *database); bool schema_install_v18(Database *database); +bool schema_install_v19(Database *database); /** * @brief Garantit la présence des extensions du schéma courant V2. diff --git a/include/models/identity_ocr.h b/include/models/identity_ocr.h index c6bfdda..875527b 100644 --- a/include/models/identity_ocr.h +++ b/include/models/identity_ocr.h @@ -33,6 +33,8 @@ gboolean identity_field_observation_set_origin( void identity_field_observation_mark_conflict(IdentityFieldObservation *field); const char *identity_field_observation_get_code( const IdentityFieldObservation *field); +const char *identity_field_observation_get_identifier( + const IdentityFieldObservation *field); const char *identity_field_observation_get_raw_value( const IdentityFieldObservation *field); const char *identity_field_observation_get_corrected_value( @@ -53,6 +55,8 @@ void identity_field_observation_clear_confirmation( IdentityFieldObservation *field); gboolean identity_field_observation_is_human_confirmed( const IdentityFieldObservation *field); +gboolean identity_field_observation_replace_identifier( + IdentityFieldObservation *field, const char *identifier); IdentityReviewStatus identity_field_observation_get_status( const IdentityFieldObservation *field); double identity_field_observation_get_confidence( diff --git a/include/models/person_ocr_projection.h b/include/models/person_ocr_projection.h new file mode 100644 index 0000000..e3f92ca --- /dev/null +++ b/include/models/person_ocr_projection.h @@ -0,0 +1,28 @@ +#ifndef LABFY_PERSON_OCR_PROJECTION_H +#define LABFY_PERSON_OCR_PROJECTION_H +#include +G_BEGIN_DECLS +typedef enum { PERSON_OCR_KEEP_EXISTING,PERSON_OCR_FILL_EMPTY,PERSON_OCR_REPLACE_EXISTING } PersonOcrProjectionStrategy; +typedef struct PersonOcrFieldProjection PersonOcrFieldProjection; +PersonOcrFieldProjection *person_ocr_field_projection_new(const char *evidence_id, + const char *run_id,const char *ocr_field_id,const char *ocr_code, + const char *confirmed_value,const char *quality,const char *review_status, + const char *person_field,const char *current_value, + PersonOcrProjectionStrategy strategy,gboolean human_confirmed); +PersonOcrFieldProjection *person_ocr_field_projection_copy(const PersonOcrFieldProjection *projection); +void person_ocr_field_projection_free(PersonOcrFieldProjection *projection); +gboolean person_ocr_field_projection_is_valid(const PersonOcrFieldProjection *projection); +const char *person_ocr_field_projection_get_evidence_id(const PersonOcrFieldProjection *p); +const char *person_ocr_field_projection_get_run_id(const PersonOcrFieldProjection *p); +const char *person_ocr_field_projection_get_ocr_field_id(const PersonOcrFieldProjection *p); +const char *person_ocr_field_projection_get_ocr_code(const PersonOcrFieldProjection *p); +const char *person_ocr_field_projection_get_confirmed_value(const PersonOcrFieldProjection *p); +const char *person_ocr_field_projection_get_quality(const PersonOcrFieldProjection *p); +const char *person_ocr_field_projection_get_review_status(const PersonOcrFieldProjection *p); +const char *person_ocr_field_projection_get_person_field(const PersonOcrFieldProjection *p); +const char *person_ocr_field_projection_get_current_value(const PersonOcrFieldProjection *p); +PersonOcrProjectionStrategy person_ocr_field_projection_get_strategy(const PersonOcrFieldProjection *p); +gboolean person_ocr_field_projection_get_human_confirmed(const PersonOcrFieldProjection *p); +gboolean person_ocr_field_projection_replace_evidence_id(PersonOcrFieldProjection *p,const char *evidence_id); +G_END_DECLS +#endif diff --git a/include/views/create_person_dialog.h b/include/views/create_person_dialog.h index 0a83db5..eae02b9 100644 --- a/include/views/create_person_dialog.h +++ b/include/views/create_person_dialog.h @@ -46,6 +46,8 @@ const GPtrArray *create_person_dialog_result_get_ocr_runs( const CreatePersonDialogResult *result); const GPtrArray *create_person_dialog_result_get_factual_relations( const CreatePersonDialogResult *result); +const GPtrArray *create_person_dialog_result_get_ocr_projections( + const CreatePersonDialogResult *result); gboolean create_person_dialog_test_overlay_has_region(GtkWindow *window); guint64 create_person_dialog_test_ocr_generation(GtkWindow *window); G_END_DECLS diff --git a/include/views/main_window.h b/include/views/main_window.h index c25e121..e96dbe8 100644 --- a/include/views/main_window.h +++ b/include/views/main_window.h @@ -326,7 +326,7 @@ void main_window_set_relation_evidences( void main_window_set_person_evidences(MainWindow *main_window, const GPtrArray *records); void main_window_set_person_factual_relations(MainWindow *main_window, - const GPtrArray *relations, const GPtrArray *evidence_records); + const GPtrArray *relations, const GPtrArray *evidence_records,const GHashTable *profile_fields); /** @brief Définit le callback de catégorisation d'une personne. */ diff --git a/include/views/person_creation_confirmation.h b/include/views/person_creation_confirmation.h new file mode 100644 index 0000000..de25e63 --- /dev/null +++ b/include/views/person_creation_confirmation.h @@ -0,0 +1,18 @@ +#ifndef LABFY_PERSON_CREATION_CONFIRMATION_H +#define LABFY_PERSON_CREATION_CONFIRMATION_H + +#include "models/identity_ocr.h" +#include "views/person_factual_relation_editor.h" +#include "views/person_ocr_projection_editor.h" +#include + +G_BEGIN_DECLS + +void person_creation_confirmation_append_sections( + GString *summary, const GPtrArray *ocr_runs, + PersonOcrProjectionEditor *projection_editor, + PersonFactualRelationEditor *relation_editor); + +G_END_DECLS + +#endif diff --git a/include/views/person_ocr_projection_editor.h b/include/views/person_ocr_projection_editor.h new file mode 100644 index 0000000..59b5657 --- /dev/null +++ b/include/views/person_ocr_projection_editor.h @@ -0,0 +1,16 @@ +#ifndef LABFY_PERSON_OCR_PROJECTION_EDITOR_H +#define LABFY_PERSON_OCR_PROJECTION_EDITOR_H +#include "models/identity_ocr.h" +#include "models/person_ocr_projection.h" +#include +G_BEGIN_DECLS +typedef struct PersonOcrProjectionEditor PersonOcrProjectionEditor; +PersonOcrProjectionEditor *person_ocr_projection_editor_new(void); +GtkWidget *person_ocr_projection_editor_get_widget(PersonOcrProjectionEditor *editor); +void person_ocr_projection_editor_set_runs(PersonOcrProjectionEditor *editor,const GPtrArray *runs); +gboolean person_ocr_projection_editor_collect(PersonOcrProjectionEditor *editor,GPtrArray **projections,GError **error); +void person_ocr_projection_editor_append_summary( + PersonOcrProjectionEditor *editor, GString *summary); +void person_ocr_projection_editor_free(PersonOcrProjectionEditor *editor); +G_END_DECLS +#endif diff --git a/include/widgets/entity_details_panel.h b/include/widgets/entity_details_panel.h index 14d02bf..167d65c 100644 --- a/include/widgets/entity_details_panel.h +++ b/include/widgets/entity_details_panel.h @@ -197,6 +197,7 @@ void entity_details_panel_set_person_evidences( EntityDetailsPanel *details_panel, const GPtrArray *evidence_records); void entity_details_panel_set_person_factual_relations( EntityDetailsPanel *details_panel, const GPtrArray *relations, const GPtrArray *evidence_records); +void entity_details_panel_set_person_profile_fields(EntityDetailsPanel *details_panel,const GHashTable *fields); /** * @brief Indique si le volet est actuellement ouvert. diff --git a/include/widgets/workspace.h b/include/widgets/workspace.h index 6e8bdea..366359e 100644 --- a/include/widgets/workspace.h +++ b/include/widgets/workspace.h @@ -474,7 +474,7 @@ void workspace_set_entity_selected_callback(Workspace *workspace, void workspace_set_person_evidences(Workspace *workspace, const GPtrArray *records); void workspace_set_person_factual_relations(Workspace *workspace, - const GPtrArray *relations, const GPtrArray *evidence_records); + const GPtrArray *relations, const GPtrArray *evidence_records,const GHashTable *profile_fields); /** * @brief Affiche le graphe chargé avec sa disposition persistée. diff --git a/src/core/application.c b/src/core/application.c index 53e5d48..105da41 100644 --- a/src/core/application.c +++ b/src/core/application.c @@ -6004,6 +6004,8 @@ static void application_on_person_creation_task_completed( } main_window_set_status(application->main_window, "Personne et preuves ajoutées. Actualisation des vues…"); + PersonCreationCoordinatorResult *created = background_task_get_result(task); + g_free(application->pending_entity_selection_identifier); application->pending_entity_selection_identifier = created != NULL ? g_strdup(created->person_identifier) : NULL; { GError *refresh_error = NULL; application_refresh_investigation_tree( @@ -6046,7 +6048,8 @@ static void application_on_person_completed( request = person_creation_task_request_new(active_database, active_root, input, create_person_dialog_result_get_evidence_selection(result), create_person_dialog_result_get_ocr_runs(result), - create_person_dialog_result_get_factual_relations(result)); + create_person_dialog_result_get_factual_relations(result), + create_person_dialog_result_get_ocr_projections(result)); task_context = g_new0(ApplicationPersonCreationTaskContext, 1); task_context->application = application; task_context->generation = application->session_generation; @@ -7067,12 +7070,14 @@ static void application_on_entity_selected(const char *entity_identifier, GError *error = NULL; GPtrArray *records = person_details_provider_get_evidences(database, entity_identifier, &error); GPtrArray *relations = NULL; + GHashTable *profile_fields = NULL; if (records != NULL) { relations = person_details_provider_get_factual_relations(database, entity_identifier, NULL); + profile_fields = person_details_provider_get_profile_fields(database, entity_identifier, NULL); } main_window_set_person_evidences(application->main_window, records); - main_window_set_person_factual_relations(application->main_window, relations, records); + main_window_set_person_factual_relations(application->main_window, relations, records,profile_fields); if (error != NULL) { application_present_error(application, "Erreur de chargement", error->message); @@ -7080,6 +7085,7 @@ static void application_on_entity_selected(const char *entity_identifier, } if (records != NULL) g_ptr_array_unref(records); if (relations != NULL) g_ptr_array_unref(relations); + if (profile_fields != NULL) g_hash_table_unref(profile_fields); } /** @brief Contexte possédé par la gestion des preuves d'une personne. */ diff --git a/src/core/person_creation_coordinator.c b/src/core/person_creation_coordinator.c index 87b5f52..7ddf747 100644 --- a/src/core/person_creation_coordinator.c +++ b/src/core/person_creation_coordinator.c @@ -1,4 +1,5 @@ #include "core/person_creation_coordinator.h" +#include "core/person_ocr_projection_service.h" #include "core/file_hash.h" #include "dao/entity_dao.h" #include "dao/evidence_dao.h" @@ -477,6 +478,30 @@ static PersonCreationCoordinatorResult *person_creation_coordinator_execute_inte options != NULL ? options->factual_relations : NULL, timestamp, &failure, error)) goto cleanup; + if (options != NULL && options->ocr_projections != NULL) { + GPtrArray *projections = g_ptr_array_new_with_free_func( + (GDestroyNotify) person_ocr_field_projection_free); + for (guint i = 0; i < options->ocr_projections->len; i++) { + PersonOcrFieldProjection *projection = + person_ocr_field_projection_copy(g_ptr_array_index( + (GPtrArray *) options->ocr_projections, i)); + for (guint j = 0; projection != NULL && ocr_runs != NULL && + j < ocr_runs->len; j++) { + IdentityOcrRun *run = g_ptr_array_index((GPtrArray *) ocr_runs, j); + if (g_strcmp0(identity_ocr_run_get_identifier(run), + person_ocr_field_projection_get_run_id(projection)) == 0) + person_ocr_field_projection_replace_evidence_id(projection, + identity_ocr_run_get_evidence_id(run)); + } + if (projection != NULL) g_ptr_array_add(projections, projection); + } + gboolean projected = !fail_at(&failure, + PERSON_CREATION_FAILURE_APPLY_OCR_PROJECTION, error) && + person_ocr_projection_service_apply(database, + result->person_identifier, projections, error); + g_ptr_array_unref(projections); + if (!projected) goto cleanup; + } for (guint i = 0; ocr_runs != NULL && i < ocr_runs->len; i++) { IdentityOcrRun *run = g_ptr_array_index((GPtrArray *) ocr_runs, i); char *directory = g_build_filename(root, "02_Preuves_Traitees", diff --git a/src/core/person_creation_task.c b/src/core/person_creation_task.c index 1c7ec26..30b9046 100644 --- a/src/core/person_creation_task.c +++ b/src/core/person_creation_task.c @@ -1,4 +1,5 @@ #include "core/person_creation_task.h" +#include "models/person_ocr_projection.h" struct PersonCreationTaskRequest { char *database_path; @@ -13,12 +14,13 @@ struct PersonCreationTaskRequest { PersonEvidenceSelection *selection; GPtrArray *ocr_runs; GPtrArray *factual_relations; + GPtrArray *ocr_projections; }; PersonCreationTaskRequest *person_creation_task_request_new( const char *database_path, const char *root, const PersonEntityInput *person, const PersonEvidenceSelection *selection, const GPtrArray *ocr_runs, - const GPtrArray *factual_relations) + const GPtrArray *factual_relations,const GPtrArray *ocr_projections) { PersonCreationTaskRequest *request; if (database_path == NULL || root == NULL || person == NULL) return NULL; @@ -65,6 +67,11 @@ PersonCreationTaskRequest *person_creation_task_request_new( g_ptr_array_add(request->factual_relations, copy_rel); } } + request->ocr_projections=g_ptr_array_new_with_free_func( + (GDestroyNotify)person_ocr_field_projection_free); + for(guint i=0;ocr_projections&&ilen;i++) + g_ptr_array_add(request->ocr_projections,person_ocr_field_projection_copy( + g_ptr_array_index((GPtrArray*)ocr_projections,i))); return request; } @@ -87,6 +94,7 @@ void person_creation_task_request_free(PersonCreationTaskRequest *request) } g_ptr_array_unref(request->factual_relations); } + g_ptr_array_unref(request->ocr_projections); g_free(request); } static gboolean worker(BackgroundTask *task, GCancellable *cancellable, @@ -102,9 +110,10 @@ static gboolean worker(BackgroundTask *task, GCancellable *cancellable, "Impossible d’ouvrir la base de l’enquête."); return FALSE; } - if (request->factual_relations != NULL && request->factual_relations->len > 0) { + if ((request->factual_relations != NULL && request->factual_relations->len > 0)||request->ocr_projections->len>0) { PersonCreationCoordinatorOptions options = {0}; options.factual_relations = request->factual_relations; + options.ocr_projections = request->ocr_projections; *result = person_creation_coordinator_execute_with_options(database, request->root, &request->person, request->selection, request->ocr_runs, &options, cancellable, error); @@ -127,7 +136,7 @@ BackgroundTask *person_creation_task_start(TaskManager *manager, task = background_task_new("Création de la personne et import des preuves"); copy = person_creation_task_request_new(request->database_path, request->root, &request->person, request->selection, - request->ocr_runs, request->factual_relations); + request->ocr_runs, request->factual_relations,request->ocr_projections); if (task == NULL || copy == NULL || !task_manager_add(manager, task, error) || !background_task_start(task, worker, copy, diff --git a/src/core/person_details_provider.c b/src/core/person_details_provider.c index 3168a62..0f53213 100644 --- a/src/core/person_details_provider.c +++ b/src/core/person_details_provider.c @@ -2,6 +2,7 @@ #include "dao/evidence_dao.h" #include "dao/evidence_entity_dao.h" #include "dao/identity_traceability_dao.h" +#include "dao/person_ocr_projection_dao.h" #include "models/evidence_record.h" GPtrArray *person_details_provider_get_evidences(Database *database, const char *person_identifier, GError **error) @@ -43,3 +44,14 @@ GPtrArray *person_details_provider_get_factual_relations(Database *database, con identity_traceability_dao_free(trace_dao); return relations; } + +GHashTable *person_details_provider_get_profile_fields(Database *database, + const char *person_identifier, GError **error) +{ + PersonOcrProjectionDao *dao = person_ocr_projection_dao_new(database); + if (dao == NULL) return NULL; + GHashTable *fields = person_ocr_projection_dao_list_profile_fields( + dao, person_identifier, error); + person_ocr_projection_dao_free(dao); + return fields; +} diff --git a/src/core/person_ocr_projection_mapping.c b/src/core/person_ocr_projection_mapping.c new file mode 100644 index 0000000..62e3f2d --- /dev/null +++ b/src/core/person_ocr_projection_mapping.c @@ -0,0 +1,22 @@ +#include "core/person_ocr_projection_mapping.h" +static const PersonOcrProjectionMapping mappings[] = { + {"surname", "surname", "Nom déclaré"}, + {"birth_name", "surname", "Nom déclaré"}, + {"given_names", "given_names", "Prénoms déclarés"}, + {"birth_date", "birth_date", "Date de naissance déclarée"}, + {"birth_place", "birth_place", "Lieu de naissance déclaré"}, + {"nationality", "nationality", "Nationalité déclarée"}, + {"sex_as_printed", "sex_as_printed", "Sexe imprimé"}, + {"address_as_printed", "address_as_printed", "Adresse imprimée"}}; +const PersonOcrProjectionMapping * +person_ocr_projection_mapping_for(const char *c) { + for (guint i = 0; i < G_N_ELEMENTS(mappings); i++) + if (g_strcmp0(c, mappings[i].ocr_code) == 0) + return &mappings[i]; + return NULL; +} +gboolean person_ocr_projection_mapping_is_compatible(const char *c, + const char *t) { + const PersonOcrProjectionMapping *m = person_ocr_projection_mapping_for(c); + return m && g_strcmp0(m->person_field, t) == 0; +} diff --git a/src/core/person_ocr_projection_service.c b/src/core/person_ocr_projection_service.c new file mode 100644 index 0000000..7180ea3 --- /dev/null +++ b/src/core/person_ocr_projection_service.c @@ -0,0 +1,154 @@ +#include "core/person_ocr_projection_service.h" +#include "core/person_ocr_projection_mapping.h" +#include "dao/entity_dao.h" +#include "dao/identity_ocr_dao.h" +#include "dao/person_ocr_projection_dao.h" +#include "database/transaction.h" +#include "models/identity_traceability.h" +static GQuark domain(void) { + return g_quark_from_static_string("person-ocr-projection-service"); +} +GPtrArray *person_ocr_projection_service_candidates(Database *d, + const char *evidence, + const char *run, + GError **error) { + if (!d || !g_uuid_string_is_valid(evidence) || + !g_uuid_string_is_valid(run)) { + g_set_error_literal(error, domain(), 1, + "La preuve ou l’exécution OCR est invalide."); + return NULL; + } + IdentityOcrDao *dao = identity_ocr_dao_new(d); + IdentityOcrRunRecord *r = + dao ? identity_ocr_dao_find_run(dao, run, error) : NULL; + if (!r || g_strcmp0(r->evidence_id, evidence) != 0) { + if (error && !*error) + g_set_error_literal(error, domain(), 1, + "L’exécution OCR ne correspond pas à la preuve."); + identity_ocr_run_record_free(r); + identity_ocr_dao_free(dao); + return NULL; + } + GPtrArray *docs = identity_ocr_dao_list_documents_by_evidence(dao, evidence, + error), + *out = g_ptr_array_new_with_free_func( + (GDestroyNotify)identity_field_observation_record_free); + if (docs == NULL) { + g_ptr_array_unref(out); + identity_ocr_run_record_free(r); + identity_ocr_dao_free(dao); + return NULL; + } + for (guint i = 0; docs && i < docs->len; i++) { + IdentityDocumentObservationRecord *doc = g_ptr_array_index(docs, i); + if (g_strcmp0(doc->ocr_run_id, run)) + continue; + GPtrArray *f = identity_ocr_dao_list_confirmed_fields(dao, doc->id, error); + if (f == NULL) { + g_ptr_array_unref(out); + g_ptr_array_unref(docs); + identity_ocr_run_record_free(r); + identity_ocr_dao_free(dao); + return NULL; + } + for (guint j = 0; f && j < f->len; j++) { + IdentityFieldObservationRecord *v = g_ptr_array_index(f, j); + if (person_ocr_projection_mapping_for(v->field_code)) + g_ptr_array_add(out, g_ptr_array_steal_index(f, j--)); + } + g_clear_pointer(&f, g_ptr_array_unref); + } + g_clear_pointer(&docs, g_ptr_array_unref); + identity_ocr_run_record_free(r); + identity_ocr_dao_free(dao); + return out; +} +gboolean person_ocr_projection_service_apply(Database *d, const char *person, + const GPtrArray *items, + GError **error) { + if (!d || !g_uuid_string_is_valid(person) || !items) { + g_set_error_literal(error, domain(), 2, "Projection invalide."); + return FALSE; + } + EntityDao *entities = entity_dao_new(d, error); + EntityRecord *entity = + entities ? entity_dao_find_by_identifier(entities, person, error) : NULL; + if (!entity || + g_strcmp0(entity_record_get_type_identifier(entity), "person")) { + if (error && !*error) + g_set_error_literal(error, domain(), 2, + "La personne cible est invalide."); + entity_record_free(entity); + entity_dao_free(entities); + return FALSE; + } + entity_record_free(entity); + entity_dao_free(entities); + PersonOcrProjectionDao *dao = person_ocr_projection_dao_new(d); + IdentityOcrDao *ocr = identity_ocr_dao_new(d); + gboolean owns = !database_transaction_is_active(d); + if (owns && !database_transaction_begin(d)) + goto fail; + GDateTime *now = g_date_time_new_now_utc(); + char *at = g_date_time_format(now, "%Y-%m-%dT%H:%M:%SZ"); + for (guint i = 0; i < items->len; i++) { + PersonOcrFieldProjection *p = g_ptr_array_index((GPtrArray *)items, i); + if (person_ocr_field_projection_get_strategy(p) == PERSON_OCR_KEEP_EXISTING) + continue; + IdentityFieldObservationRecord *f = identity_ocr_dao_find_field( + ocr, person_ocr_field_projection_get_ocr_field_id(p), error); + char *current = person_ocr_projection_dao_get_value( + dao, person, person_ocr_field_projection_get_person_field(p), error); + if (error && *error) { + identity_field_observation_record_free(f); + g_free(current); + g_free(at); + g_date_time_unref(now); + goto fail; + } + gboolean valid = + f && + identity_traceability_field_is_projectable( + f->review_status, f->value_quality, f->confirmation_state, + f->confirmed_value) && + g_strcmp0(f->evidence_id, + person_ocr_field_projection_get_evidence_id(p)) == 0 && + g_strcmp0(f->ocr_run_id, person_ocr_field_projection_get_run_id(p)) == + 0 && + g_strcmp0(f->field_code, person_ocr_field_projection_get_ocr_code(p)) == + 0 && + g_strcmp0(f->confirmed_value, + person_ocr_field_projection_get_confirmed_value(p)) == 0 && + person_ocr_projection_mapping_is_compatible( + f->field_code, person_ocr_field_projection_get_person_field(p)) && + g_strcmp0(current, person_ocr_field_projection_get_current_value(p)) == + 0 && + ((!current && person_ocr_field_projection_get_strategy(p) == + PERSON_OCR_FILL_EMPTY) || + (current && person_ocr_field_projection_get_strategy(p) == + PERSON_OCR_REPLACE_EXISTING)); + identity_field_observation_record_free(f); + g_free(current); + if (!valid || !person_ocr_projection_dao_apply(dao, person, p, at, error)) { + g_free(at); + g_date_time_unref(now); + goto fail; + } + } + g_free(at); + g_date_time_unref(now); + if (owns && !database_transaction_commit(d)) + goto fail; + person_ocr_projection_dao_free(dao); + identity_ocr_dao_free(ocr); + return TRUE; +fail: + if (owns && database_transaction_is_active(d)) + database_transaction_rollback(d); + if (error && !*error) + g_set_error_literal(error, domain(), 3, + "La valeur OCR a changé ou n’est plus projetable."); + person_ocr_projection_dao_free(dao); + identity_ocr_dao_free(ocr); + return FALSE; +} diff --git a/src/dao/identity_ocr_dao.c b/src/dao/identity_ocr_dao.c index a5a41db..68b26a2 100644 --- a/src/dao/identity_ocr_dao.c +++ b/src/dao/identity_ocr_dao.c @@ -64,7 +64,7 @@ gboolean identity_ocr_dao_insert(IdentityOcrDao*d,const char*person, const GPtrArray*fields=identity_ocr_run_get_fields(r); for(guint i=0;fields&&ilen;i++){IdentityFieldObservation*f=g_ptr_array_index((GPtrArray*)fields,i); IdentityReviewStatus status=identity_field_observation_get_status(f); - const IdentitySourceBox*b=identity_field_observation_get_box(f);char*id=g_uuid_string_random(); + const IdentitySourceBox*b=identity_field_observation_get_box(f);const char*id=identity_field_observation_get_identifier(f); s=database_statement_prepare(d->database,"INSERT INTO identity_field_observations(" "id,observation_id,field_code,raw_value,corrected_value,normalized_value," "confidence,review_status," @@ -92,7 +92,7 @@ gboolean identity_ocr_dao_insert(IdentityOcrDao*d,const char*person, ?"human_confirmed":"unconfirmed")&& bind_text(s,23,identity_field_observation_get_value_quality(f))&& database_statement_step(s)==DATABASE_STATEMENT_STEP_DONE; - database_statement_finalize(s);g_free(id);if(!ok){g_free(obs);goto fail;}} + database_statement_finalize(s);if(!ok){g_free(obs);goto fail;}} g_free(obs);return TRUE; fail:g_set_error_literal(error,g_quark_from_static_string("identity-ocr-dao"),1, "Impossible de conserver l’OCR d’identité.");return FALSE; @@ -357,6 +357,7 @@ IdentityOcrRun *identity_ocr_dao_load_run( :identity_field_observation_new_manual(f->field_code, f->corrected_value,(guint)f->display_order); if(field==NULL)continue; + identity_field_observation_replace_identifier(field,f->id); if(f->corrected_value!=NULL) identity_field_observation_modify(field,f->corrected_value,f->review_note); if(g_strcmp0(f->review_status,"accepted")==0) @@ -393,7 +394,7 @@ static gboolean update_review_fields(IdentityOcrDao*d,const char*observation, for(guint i=0;fields!=NULL&&ilen;i++){ IdentityFieldObservation*f=g_ptr_array_index((GPtrArray*)fields,i); const IdentitySourceBox*b=identity_field_observation_get_box(f); - char*id=g_uuid_string_random(); + const char*id=identity_field_observation_get_identifier(f); DatabaseStatement*s=database_statement_prepare(d->database, "INSERT INTO identity_field_observations(id,observation_id,field_code," "raw_value,corrected_value,normalized_value,confidence,review_status,origin,evidence_id," @@ -431,7 +432,7 @@ static gboolean update_review_fields(IdentityOcrDao*d,const char*observation, ?"human_confirmed":"unconfirmed")&& bind_text(s,23,identity_field_observation_get_value_quality(f))&& database_statement_step(s)==DATABASE_STATEMENT_STEP_DONE; - database_statement_finalize(s);g_free(id);if(!ok)return FALSE; + database_statement_finalize(s);if(!ok)return FALSE; } return TRUE; } diff --git a/src/dao/person_ocr_projection_dao.c b/src/dao/person_ocr_projection_dao.c new file mode 100644 index 0000000..27742bd --- /dev/null +++ b/src/dao/person_ocr_projection_dao.c @@ -0,0 +1,168 @@ +#include "dao/person_ocr_projection_dao.h" +#include "database/statement.h" +struct PersonOcrProjectionDao { + Database *database; +}; +static GQuark domain(void) { + return g_quark_from_static_string("person-ocr-projection-dao"); +} +PersonOcrProjectionDao *person_ocr_projection_dao_new(Database *d) { + if (!d) + return NULL; + PersonOcrProjectionDao *x = g_new0(PersonOcrProjectionDao, 1); + x->database = d; + return x; +} +void person_ocr_projection_dao_free(PersonOcrProjectionDao *d) { g_free(d); } +char *person_ocr_projection_dao_get_value(PersonOcrProjectionDao *d, + const char *p, const char *f, + GError **e) { + if (!d || !p || !f) + return NULL; + DatabaseStatement *s = database_statement_prepare( + d->database, "SELECT value FROM person_profile_fields WHERE person_id=? " + "AND field_code=?;"); + char *v = NULL; + if (!s || !database_statement_bind_text(s, 1, p) || + !database_statement_bind_text(s, 2, f)) + goto fail; + DatabaseStatementStepResult step = database_statement_step(s); + if (step == DATABASE_STATEMENT_STEP_ROW) + database_statement_column_text(s, 0, &v); + else if (step != DATABASE_STATEMENT_STEP_DONE) + goto fail; + database_statement_finalize(s); + return v; +fail: + database_statement_finalize(s); + g_set_error_literal(e, domain(), 1, + "Impossible de lire le champ de la personne."); + return NULL; +} +gboolean person_ocr_projection_dao_apply(PersonOcrProjectionDao *d, + const char *person, + const PersonOcrFieldProjection *p, + const char *at, GError **e) { + if (!d || !person_ocr_field_projection_is_valid(p) || !at) + return FALSE; + const char *target = person_ocr_field_projection_get_person_field(p), + *value = person_ocr_field_projection_get_confirmed_value(p); + DatabaseStatement *s = database_statement_prepare( + d->database, + "INSERT INTO " + "person_profile_fields(person_id,field_code,value,updated_at) " + "VALUES(?,?,?,?) ON CONFLICT(person_id,field_code) DO UPDATE SET " + "value=excluded.value,updated_at=excluded.updated_at;"); + gboolean ok = s && database_statement_bind_text(s, 1, person) && + database_statement_bind_text(s, 2, target) && + database_statement_bind_text(s, 3, value) && + database_statement_bind_text(s, 4, at) && + database_statement_step(s) == DATABASE_STATEMENT_STEP_DONE; + database_statement_finalize(s); + if (!ok) + goto fail; + char *id = g_uuid_string_random(); + s = database_statement_prepare(d->database, + "INSERT INTO person_ocr_field_projections " + "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?);"); + const char *previous = person_ocr_field_projection_get_current_value(p); + const char *strategy = + person_ocr_field_projection_get_strategy(p) == PERSON_OCR_REPLACE_EXISTING + ? "replace_existing" + : "fill_empty"; + ok = s && database_statement_bind_text(s, 1, id) && + database_statement_bind_text(s, 2, person) && + database_statement_bind_text(s, 3, target) && + (previous ? database_statement_bind_text(s, 4, previous) + : database_statement_bind_null(s, 4)) && + database_statement_bind_text(s, 5, value) && + database_statement_bind_text( + s, 6, person_ocr_field_projection_get_evidence_id(p)) && + database_statement_bind_text( + s, 7, person_ocr_field_projection_get_run_id(p)) && + database_statement_bind_text( + s, 8, person_ocr_field_projection_get_ocr_field_id(p)) && + database_statement_bind_text( + s, 9, person_ocr_field_projection_get_ocr_code(p)) && + database_statement_bind_text( + s, 10, person_ocr_field_projection_get_quality(p)) && + database_statement_bind_text( + s, 11, person_ocr_field_projection_get_review_status(p)) && + database_statement_bind_text(s, 12, strategy) && + database_statement_bind_text(s, 13, at) && + database_statement_bind_text(s, 14, "human") && + database_statement_step(s) == DATABASE_STATEMENT_STEP_DONE; + database_statement_finalize(s); + g_free(id); + if (ok) + return TRUE; +fail: + g_set_error_literal(e, domain(), 2, + "Impossible de conserver la projection OCR."); + return FALSE; +} +GPtrArray *person_ocr_projection_dao_list(PersonOcrProjectionDao *d, + const char *p, GError **e) { + GPtrArray *a = g_ptr_array_new_with_free_func(g_free); + DatabaseStatement *s = + d ? database_statement_prepare( + d->database, "SELECT id FROM person_ocr_field_projections WHERE " + "person_id=? ORDER BY projected_at,id;") + : NULL; + if (!s || !database_statement_bind_text(s, 1, p)) + goto fail; + for (;;) { + DatabaseStatementStepResult step = database_statement_step(s); + if (step == DATABASE_STATEMENT_STEP_DONE) + break; + if (step != DATABASE_STATEMENT_STEP_ROW) + goto fail; + char *id = NULL; + if (!database_statement_column_text(s, 0, &id)) + goto fail; + g_ptr_array_add(a, id); + } + database_statement_finalize(s); + return a; +fail: + database_statement_finalize(s); + g_ptr_array_unref(a); + g_set_error_literal(e, domain(), 3, + "Impossible de lire les projections OCR."); + return NULL; +} +GHashTable *person_ocr_projection_dao_list_profile_fields( + PersonOcrProjectionDao *d, const char *person, GError **e) { + GHashTable *fields = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, + g_free); + DatabaseStatement *s = d ? database_statement_prepare( + d->database, + "SELECT field_code,value FROM " + "person_profile_fields WHERE person_id=? " + "ORDER BY field_code;") + : NULL; + if (!s || !database_statement_bind_text(s, 1, person)) + goto fail; + for (;;) { + DatabaseStatementStepResult step = database_statement_step(s); + if (step == DATABASE_STATEMENT_STEP_DONE) + break; + char *field = NULL, *value = NULL; + if (step != DATABASE_STATEMENT_STEP_ROW || + !database_statement_column_text(s, 0, &field) || + !database_statement_column_text(s, 1, &value)) { + g_free(field); + g_free(value); + goto fail; + } + g_hash_table_insert(fields, field, value); + } + database_statement_finalize(s); + return fields; +fail: + database_statement_finalize(s); + g_hash_table_unref(fields); + g_set_error_literal(e, domain(), 4, + "Impossible de lire le profil structuré."); + return NULL; +} diff --git a/src/database/database.c b/src/database/database.c index 4e11d81..09f6e6d 100644 --- a/src/database/database.c +++ b/src/database/database.c @@ -16,12 +16,12 @@ /** * @brief Version actuelle du schéma SQLite. */ -#define DATABASE_SCHEMA_VERSION_CURRENT 18 +#define DATABASE_SCHEMA_VERSION_CURRENT 19 /** * @brief Version actuelle sous forme textuelle pour metadata. */ -#define DATABASE_SCHEMA_VERSION_CURRENT_TEXT "18" +#define DATABASE_SCHEMA_VERSION_CURRENT_TEXT "19" /** * @brief Nom de l'application enregistré dans les métadonnées. @@ -932,6 +932,21 @@ rollback: return false; } +static bool database_migrate_v18_to_v19(Database *database) +{ + bool transaction_started = false; + if (database == NULL || !database_transaction_begin(database)) return false; + transaction_started = true; + if (!schema_install_v19(database) || + !database_update_schema_version(database, "19") || + !database_transaction_commit(database)) goto rollback; + return true; +rollback: + if (transaction_started && !database_transaction_rollback(database)) + g_warning("Impossible d’annuler la migration SQLite V18 vers V19."); + return false; +} + /** * @brief Garantit atomiquement la présence des extensions du schéma courant. */ @@ -1193,6 +1208,10 @@ bool database_migrate_to_latest( if (!database_migrate_v17_to_v18(database)) return false; schema_version = 18; break; + case 18: + if (!database_migrate_v18_to_v19(database)) return false; + schema_version = 19; + break; default: database_set_error( @@ -1335,7 +1354,8 @@ bool database_initialize( if (!schema_ensure_current( database - )) + ) || + !schema_install_v19(database)) { goto rollback; } diff --git a/src/database/schema.c b/src/database/schema.c index 8f78c7a..d0e0d5e 100644 --- a/src/database/schema.c +++ b/src/database/schema.c @@ -433,6 +433,12 @@ bool schema_install_v18(Database *database) "la migration SQLite V18"); } +bool schema_install_v19(Database *database) +{ + return schema_execute_file(database, "database/schema_v19.sql", + "la migration SQLite V19"); +} + bool schema_ensure_current( Database *database ) diff --git a/src/models/identity_ocr.c b/src/models/identity_ocr.c index 71c957c..bbb3e36 100644 --- a/src/models/identity_ocr.c +++ b/src/models/identity_ocr.c @@ -1,6 +1,6 @@ #include "models/identity_ocr.h" struct IdentityFieldObservation { - char *code, *raw_value, *corrected_value, *normalized_value, *note; + char *id, *code, *raw_value, *corrected_value, *normalized_value, *note; char *confirmed_value, *value_quality; char *origin; double confidence; @@ -47,7 +47,7 @@ IdentityFieldObservation *identity_field_observation_new( if (!identity_ocr_field_code_is_valid(code) || raw == NULL || raw[0]=='\0' || confidence < -1.0 || confidence > 100.0) return NULL; IdentityFieldObservation *f=g_new0(IdentityFieldObservation,1); - f->code=g_strdup(code); f->raw_value=g_strdup(raw); + f->id=g_uuid_string_random();f->code=g_strdup(code); f->raw_value=g_strdup(raw); f->confidence=confidence; f->status=IDENTITY_REVIEW_PROPOSED; f->origin=g_strdup("ocr"); f->value_quality=g_strdup("complete"); f->order=order; if (box != NULL) f->box=*box; @@ -60,7 +60,7 @@ IdentityFieldObservation *identity_field_observation_new_manual( value[0] == '\0') return NULL; IdentityFieldObservation *field = g_new0(IdentityFieldObservation, 1); - field->code = g_strdup(code); + field->id = g_uuid_string_random();field->code = g_strdup(code); field->corrected_value = g_strdup(value); field->confidence = -1.0; field->status = IDENTITY_REVIEW_PROPOSED; @@ -79,6 +79,7 @@ IdentityFieldObservation *identity_field_observation_copy( : identity_field_observation_new_manual( f->code,f->corrected_value,f->order); c->corrected_value=g_strdup(f->corrected_value); + g_free(c->id);c->id=g_strdup(f->id); c->normalized_value=g_strdup(f->normalized_value); c->confirmed_value=g_strdup(f->confirmed_value); g_free(c->value_quality);c->value_quality=g_strdup(f->value_quality); @@ -88,7 +89,7 @@ IdentityFieldObservation *identity_field_observation_copy( void identity_field_observation_free(IdentityFieldObservation *f) { if(f==NULL)return; - g_free(f->code);g_free(f->raw_value); + g_free(f->id);g_free(f->code);g_free(f->raw_value); g_free(f->corrected_value);g_free(f->normalized_value);g_free(f->note); g_free(f->origin);g_free(f->confirmed_value);g_free(f->value_quality);g_free(f); } @@ -129,7 +130,7 @@ void identity_field_observation_mark_conflict(IdentityFieldObservation*f) g_clear_pointer(&f->confirmed_value,g_free);}} #define FG(name,type,field,zero) type identity_field_observation_get_##name(\ const IdentityFieldObservation*f){return f!=NULL?f->field:zero;} -FG(code,const char*,code,NULL) FG(raw_value,const char*,raw_value,NULL) +FG(identifier,const char*,id,NULL) FG(code,const char*,code,NULL) FG(raw_value,const char*,raw_value,NULL) FG(corrected_value,const char*,corrected_value,NULL) FG(normalized_value,const char*,normalized_value,NULL) FG(confirmed_value,const char*,confirmed_value,NULL) @@ -140,6 +141,8 @@ FG(confidence,double,confidence,-1.0) FG(order,guint,order,0) const IdentitySourceBox *identity_field_observation_get_box( const IdentityFieldObservation*f){return f!=NULL?&f->box:NULL;} +gboolean identity_field_observation_replace_identifier(IdentityFieldObservation*f,const char*id) +{if(!f||!g_uuid_string_is_valid(id))return FALSE;g_free(f->id);f->id=g_strdup(id);return TRUE;} gboolean identity_field_observation_set_normalized_value( IdentityFieldObservation*f,const char*v) {if(!f||!v||!v[0]||!g_utf8_validate(v,-1,NULL))return FALSE; diff --git a/src/models/person_ocr_projection.c b/src/models/person_ocr_projection.c new file mode 100644 index 0000000..950f119 --- /dev/null +++ b/src/models/person_ocr_projection.c @@ -0,0 +1,97 @@ +#include "models/person_ocr_projection.h" +struct PersonOcrFieldProjection { + char *evidence, *run, *field, *ocr_code, *value, *quality, *status, *target, + *current; + PersonOcrProjectionStrategy strategy; + gboolean confirmed; +}; +static gboolean text(const char *v) { + return v && *v && g_utf8_validate(v, -1, NULL); +} +PersonOcrFieldProjection *person_ocr_field_projection_new( + const char *e, const char *r, const char *f, const char *c, const char *v, + const char *q, const char *s, const char *t, const char *current, + PersonOcrProjectionStrategy strategy, gboolean confirmed) { + PersonOcrFieldProjection *p = g_new0(PersonOcrFieldProjection, 1); + p->evidence = g_strdup(e); + p->run = g_strdup(r); + p->field = g_strdup(f); + p->ocr_code = g_strdup(c); + p->value = g_strdup(v); + p->quality = g_strdup(q); + p->status = g_strdup(s); + p->target = g_strdup(t); + p->current = g_strdup(current); + p->strategy = strategy; + p->confirmed = confirmed; + if (!person_ocr_field_projection_is_valid(p)) { + person_ocr_field_projection_free(p); + return NULL; + } + return p; +} +gboolean +person_ocr_field_projection_is_valid(const PersonOcrFieldProjection *p) { + return p && g_uuid_string_is_valid(p->evidence) && + g_uuid_string_is_valid(p->run) && g_uuid_string_is_valid(p->field) && + text(p->ocr_code) && text(p->value) && text(p->quality) && + text(p->status) && text(p->target) && + (p->current == NULL || g_utf8_validate(p->current, -1, NULL)) && + p->confirmed && + (g_str_equal(p->quality, "complete") || + g_str_equal(p->quality, "partial")) && + (g_str_equal(p->status, "accepted") || + g_str_equal(p->status, "modified")) && + (p->strategy == PERSON_OCR_KEEP_EXISTING || + p->strategy == PERSON_OCR_FILL_EMPTY || + p->strategy == PERSON_OCR_REPLACE_EXISTING); +} +PersonOcrFieldProjection * +person_ocr_field_projection_copy(const PersonOcrFieldProjection *p) { + return p ? person_ocr_field_projection_new(p->evidence, p->run, p->field, + p->ocr_code, p->value, p->quality, + p->status, p->target, p->current, + p->strategy, p->confirmed) + : NULL; +} +void person_ocr_field_projection_free(PersonOcrFieldProjection *p) { + if (!p) + return; + g_free(p->evidence); + g_free(p->run); + g_free(p->field); + g_free(p->ocr_code); + g_free(p->value); + g_free(p->quality); + g_free(p->status); + g_free(p->target); + g_free(p->current); + g_free(p); +} +#define GET(name, field) \ + const char *person_ocr_field_projection_get_##name( \ + const PersonOcrFieldProjection *p) { \ + return p ? p->field : NULL; \ + } +GET(evidence_id, evidence) +GET(run_id, run) GET(ocr_field_id, field) GET(ocr_code, ocr_code) + GET(confirmed_value, value) GET(quality, quality) GET(review_status, status) + GET(person_field, target) + GET(current_value, current) PersonOcrProjectionStrategy + person_ocr_field_projection_get_strategy( + const PersonOcrFieldProjection *p) { + return p ? p->strategy : PERSON_OCR_KEEP_EXISTING; +} +gboolean person_ocr_field_projection_get_human_confirmed( + const PersonOcrFieldProjection *p) { + return p && p->confirmed; +} +gboolean +person_ocr_field_projection_replace_evidence_id(PersonOcrFieldProjection *p, + const char *e) { + if (!p || !g_uuid_string_is_valid(e)) + return FALSE; + g_free(p->evidence); + p->evidence = g_strdup(e); + return TRUE; +} diff --git a/src/views/create_person_dialog.c b/src/views/create_person_dialog.c index ae3af1f..63d2393 100644 --- a/src/views/create_person_dialog.c +++ b/src/views/create_person_dialog.c @@ -1,12 +1,10 @@ -/****************************************************************************** - * @file create_person_dialog.c - * @brief Formulaire GTK de création d'une personne observée. - ******************************************************************************/ #include "views/create_person_dialog.h" #include "views/dialog_geometry.h" #include "views/person_vocabulary_adapter.h" #include "views/identity_ocr_option_adapter.h" #include "views/person_factual_relation_editor.h" +#include "views/person_ocr_projection_editor.h" +#include "views/person_creation_confirmation.h" #include "core/evidence_staging.h" #include "core/evidence_staging_task.h" #include "core/person_confirmation_summary.h" @@ -35,6 +33,7 @@ struct CreatePersonDialogResult EvidenceStaging *staging; GPtrArray *ocr_runs; GPtrArray *factual_relations; + GPtrArray *ocr_projections; }; typedef struct { @@ -110,6 +109,7 @@ typedef struct gboolean updating_evidence; gboolean updating_retained; PersonFactualRelationEditor *factual_relation_editor; + PersonOcrProjectionEditor *ocr_projection_editor; } CreatePersonDialogState; typedef struct { @@ -204,7 +204,6 @@ static void identity_languages_completed(GObject *source, g_clear_object(&window); } -/** @brief Extrait les notes de la zone de texte. */ static char *create_person_dialog_get_notes(CreatePersonDialogState *state) { GtkTextBuffer *buffer = gtk_text_view_get_buffer(state->notes); @@ -676,7 +675,6 @@ static void create_person_dialog_on_ocr_start(GtkButton *button, "OCR contrôlé en cours… Aucune donnée n’est encore persistée."); g_task_run_in_thread(state->ocr_task, create_person_dialog_ocr_worker); } -/** @brief Libère l'état attaché à la fenêtre. */ static void create_person_dialog_state_free(gpointer data) { CreatePersonDialogState *state = data; @@ -712,6 +710,7 @@ static void create_person_dialog_state_free(gpointer data) g_free(state->tesseract_path); g_free(state->tesseract_version); person_factual_relation_editor_free(state->factual_relation_editor); + person_ocr_projection_editor_free(state->ocr_projection_editor); if (state->user_data_destroy != NULL) state->user_data_destroy(state->user_data); g_free(state); @@ -1170,7 +1169,6 @@ static void create_person_dialog_on_evidence_changed( state->selection_model, identifier); create_person_dialog_select_record(state, record, NULL); } -/** @brief Termine le dialogue comme une annulation. */ static void create_person_dialog_cancel(CreatePersonDialogState *state) { if (state == NULL || @@ -1231,18 +1229,15 @@ static void create_person_dialog_select_record(CreatePersonDialogState *state, evidence_preview_request_free(request); g_free(size); g_free(sha); g_free(text); } -/** @brief Traite la fermeture native. */ static gboolean create_person_dialog_on_close(GtkWindow *window, gpointer data) { (void) window; create_person_dialog_cancel(data); return FALSE; } -/** @brief Traite le bouton Annuler. */ static void create_person_dialog_on_cancel(GtkButton *button, gpointer data) { CreatePersonDialogState *state = data; (void) button; create_person_dialog_cancel(state); gtk_window_close(state->window); } -/** @brief Valide puis transmet les valeurs du formulaire. */ static void create_person_dialog_on_create(GtkButton *button, gpointer data) { CreatePersonDialogState *state = data; @@ -1304,6 +1299,11 @@ static void create_person_dialog_on_create(GtkButton *button, gpointer data) g_clear_error(&relation_error); create_person_dialog_result_free(result); g_free(notes); return; } + if (!person_ocr_projection_editor_collect(state->ocr_projection_editor,&result->ocr_projections,&relation_error)) { + gtk_label_set_text(state->error, relation_error->message); + gtk_widget_set_visible(GTK_WIDGET(state->error), TRUE); + g_clear_error(&relation_error);create_person_dialog_result_free(result);g_free(notes);return; + } result->evidence_selection = state->person_evidence_selection; state->person_evidence_selection = NULL; result->staging = state->staging; @@ -1323,10 +1323,11 @@ static void create_person_dialog_on_create(GtkButton *button, gpointer data) static void create_person_dialog_update_navigation(CreatePersonDialogState *state) { static const char *const pages[] = { - "person", "roles", "evidence", "identity-ocr", "factual-relations", "summary"}; + "person", "roles", "evidence", "identity-ocr", "ocr-projection", + "factual-relations", "summary"}; static const char *const steps[] = { "1 Personne", "2 Rôles", "3 Preuves", "4 OCR identité", - "5 Relations factuelles", "6 Confirmation"}; + "5 Projection OCR", "6 Relations factuelles", "7 Confirmation"}; GString *progress = g_string_new(NULL); gtk_stack_set_visible_child_name(state->stack, pages[state->step]); for (guint i = 0; i < G_N_ELEMENTS(steps); i++) { @@ -1340,7 +1341,7 @@ static void create_person_dialog_update_navigation(CreatePersonDialogState *stat } gtk_label_set_markup(state->progress, progress->str); g_string_free(progress, TRUE); - if (state->step == 5) { + if (state->step == 6) { GPtrArray *role_labels = person_vocabulary_adapter_selected_role_labels( state->role_buttons); @@ -1358,52 +1359,9 @@ static void create_person_dialog_update_navigation(CreatePersonDialogState *stat gtk_spin_button_get_value_as_int(state->confidence), notes, role_labels, state->person_evidence_selection); GString *confirmation = g_string_new(text); - person_factual_relation_editor_append_summary(state->factual_relation_editor, - confirmation); - g_string_append(confirmation, - "\n\nOCR identité\nL’OCR peut contenir des erreurs. Ces " - "informations sont seulement présentées sur le document ; " - "l’authenticité n’est pas établie et aucune fusion de personne " - "ne sera effectuée. Aucune écriture n’a encore eu lieu."); - for (guint run_index = 0; - state->ocr_runs != NULL && run_index < state->ocr_runs->len; - run_index++) { - IdentityOcrRun *run = g_ptr_array_index( - state->ocr_runs, run_index); - g_string_append_printf(confirmation, - "\n• %s — %s — page %u — Tesseract %s — %s", - identity_ocr_run_get_document_type(run), - identity_ocr_run_get_document_side(run), - identity_ocr_run_get_page(run), - identity_ocr_run_get_version(run) != NULL - ? identity_ocr_run_get_version(run) : "version inconnue", - identity_ocr_run_get_languages(run)); - if (identity_ocr_run_get_factual_notes(run) != NULL) - g_string_append_printf(confirmation, - "\n Notes factuelles : %s", - identity_ocr_run_get_factual_notes(run)); - const GPtrArray *fields = identity_ocr_run_get_fields(run); - for (guint field_index = 0; - fields != NULL && field_index < fields->len; field_index++) { - IdentityFieldObservation *field = - g_ptr_array_index((GPtrArray *) fields, field_index); - IdentityReviewStatus review = - identity_field_observation_get_status(field); - if (review == IDENTITY_REVIEW_ACCEPTED || - review == IDENTITY_REVIEW_MODIFIED) - g_string_append_printf(confirmation, - "\n - %s : %s (brut : %s ; origine : %s)", - identity_field_observation_get_code(field), - identity_field_observation_get_corrected_value(field) - != NULL - ? identity_field_observation_get_corrected_value(field) - : identity_field_observation_get_raw_value(field), - identity_field_observation_get_raw_value(field) != NULL - ? identity_field_observation_get_raw_value(field) - : "absente", - identity_field_observation_get_origin(field)); - } - } + person_creation_confirmation_append_sections(confirmation, + state->ocr_runs, state->ocr_projection_editor, + state->factual_relation_editor); gtk_label_set_text(state->summary, confirmation->str); GtkAdjustment *adjustment = gtk_scrolled_window_get_vadjustment( state->summary_scroll); @@ -1415,10 +1373,10 @@ static void create_person_dialog_update_navigation(CreatePersonDialogState *stat g_ptr_array_unref(role_labels); } gtk_widget_set_sensitive(GTK_WIDGET(state->previous), state->step > 0); - gtk_widget_set_visible(GTK_WIDGET(state->next), state->step < 5); - gtk_widget_set_visible(GTK_WIDGET(state->create), state->step == 5); + gtk_widget_set_visible(GTK_WIDGET(state->next), state->step < 6); + gtk_widget_set_visible(GTK_WIDGET(state->create), state->step == 6); gtk_widget_set_sensitive(GTK_WIDGET(state->create), - state->step == 5 && + state->step == 6 && gtk_editable_get_text(GTK_EDITABLE(state->designation))[0] != '\0' && person_evidence_selection_is_confirmable( state->person_evidence_selection)); @@ -1439,11 +1397,24 @@ static void create_person_dialog_on_next(GtkButton *button, gpointer data) gtk_widget_set_visible(GTK_WIDGET(state->error), TRUE); return; } - if (state->step == 3) + if (state->step == 3) { create_person_dialog_capture_corrected_transcription(state); - if (state->step == 3) create_person_dialog_capture_factual_notes(state); + person_ocr_projection_editor_set_runs(state->ocr_projection_editor, + state->ocr_runs); + } if (state->step == 4) { + GError *error = NULL; + GPtrArray *projections = NULL; + if (!person_ocr_projection_editor_collect(state->ocr_projection_editor, + &projections, &error)) { + gtk_label_set_text(state->error, error->message); + gtk_widget_set_visible(GTK_WIDGET(state->error), TRUE); + g_clear_error(&error); return; + } + g_ptr_array_unref(projections); + } + if (state->step == 5) { GError *error = NULL; if (!person_factual_relation_editor_validate(state->factual_relation_editor, &error)) { @@ -1453,10 +1424,9 @@ static void create_person_dialog_on_next(GtkButton *button, gpointer data) } } gtk_widget_set_visible(GTK_WIDGET(state->error), FALSE); - if (state->step < 5) state->step++; + if (state->step < 6) state->step++; create_person_dialog_update_navigation(state); } -/** @brief Ajoute une ligne libellée au formulaire. */ static void create_person_dialog_add_row(GtkGrid *grid, int row, const char *label, GtkWidget *widget) { @@ -1583,6 +1553,8 @@ gboolean create_person_dialog_present(GtkWindow *parent, create_person_dialog_add_row(GTK_GRID(grid), 4, "Confiance (%)", GTK_WIDGET(state->confidence)); create_person_dialog_add_row(GTK_GRID(grid), 5, "Notes factuelles", GTK_WIDGET(state->notes)); state->stack = GTK_STACK(gtk_stack_new()); + gtk_widget_set_name(GTK_WIDGET(state->stack), + "create-person-assistant-stack"); gtk_stack_set_transition_type(state->stack, GTK_STACK_TRANSITION_TYPE_SLIDE_LEFT_RIGHT); gtk_stack_add_titled(state->stack, grid, "person", "1 — Personne"); @@ -1861,19 +1833,28 @@ gboolean create_person_dialog_present(GtkWindow *parent, gtk_scrolled_window_set_child(state->summary_scroll, summary); state->factual_relation_editor = person_factual_relation_editor_new(); + state->ocr_projection_editor = person_ocr_projection_editor_new(); person_factual_relation_editor_set_available_evidence(state->factual_relation_editor, state->evidence_labels, state->evidence_identifiers); person_factual_relation_editor_set_available_ocr_runs(state->factual_relation_editor, state->ocr_runs); - GtkWidget *relation_scroll = gtk_scrolled_window_new(); gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(relation_scroll), + GtkWidget *projection_scroll = gtk_scrolled_window_new(); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(projection_scroll), + GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(projection_scroll), + person_ocr_projection_editor_get_widget(state->ocr_projection_editor)); + gtk_stack_add_titled(state->stack, projection_scroll, "ocr-projection", + "5 — Projection OCR"); + GtkWidget *relation_scroll = gtk_scrolled_window_new(); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(relation_scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(relation_scroll), person_factual_relation_editor_get_widget(state->factual_relation_editor)); gtk_stack_add_titled(state->stack, relation_scroll, "factual-relations", - "5 — Relations factuelles"); + "6 — Relations factuelles"); gtk_stack_add_titled(state->stack, - GTK_WIDGET(state->summary_scroll), "summary", "6 — Confirmation"); + GTK_WIDGET(state->summary_scroll), "summary", "7 — Confirmation"); state->error = GTK_LABEL(gtk_label_new(NULL)); gtk_widget_set_visible(GTK_WIDGET(state->error), FALSE); actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); gtk_widget_set_name(actions, "create-person-actions"); @@ -1955,6 +1936,7 @@ void create_person_dialog_result_free(CreatePersonDialogResult *result) if (result->factual_relations) { g_ptr_array_unref(result->factual_relations); } + g_clear_pointer(&result->ocr_projections, g_ptr_array_unref); g_free(result); } const PersonEntityInput *create_person_dialog_result_get_input( @@ -1985,16 +1967,6 @@ const GPtrArray *create_person_dialog_result_get_factual_relations( { return result != NULL ? result->factual_relations : NULL; } -gboolean create_person_dialog_test_overlay_has_region(GtkWindow *window) -{ - CreatePersonDialogState *state = window != NULL - ? g_object_get_data(G_OBJECT(window), "person-dialog-state") : NULL; - return state != NULL && - ocr_provenance_overlay_has_region(state->ocr_overlay); -} -guint64 create_person_dialog_test_ocr_generation(GtkWindow *window) -{ - CreatePersonDialogState *state = window != NULL - ? g_object_get_data(G_OBJECT(window), "person-dialog-state") : NULL; - return state != NULL ? state->ocr_generation : 0; -} +const GPtrArray *create_person_dialog_result_get_ocr_projections(const CreatePersonDialogResult *r){return r?r->ocr_projections:NULL;} +gboolean create_person_dialog_test_overlay_has_region(GtkWindow*w){CreatePersonDialogState*s=w?g_object_get_data(G_OBJECT(w),"person-dialog-state"):NULL;return s&&ocr_provenance_overlay_has_region(s->ocr_overlay);} +guint64 create_person_dialog_test_ocr_generation(GtkWindow*w){CreatePersonDialogState*s=w?g_object_get_data(G_OBJECT(w),"person-dialog-state"):NULL;return s?s->ocr_generation:0;} diff --git a/src/views/main_window.c b/src/views/main_window.c index b6d72b8..8d7e777 100644 --- a/src/views/main_window.c +++ b/src/views/main_window.c @@ -1965,8 +1965,8 @@ void main_window_set_entity_selected_callback(MainWindow *main_window, void main_window_set_person_evidences(MainWindow *main_window, const GPtrArray *records) { if (main_window) workspace_set_person_evidences(main_window->workspace, records); } -void main_window_set_person_factual_relations(MainWindow *main_window, const GPtrArray *relations, const GPtrArray *evidence_records) { - if (main_window) workspace_set_person_factual_relations(main_window->workspace, relations, evidence_records); +void main_window_set_person_factual_relations(MainWindow *main_window, const GPtrArray *relations, const GPtrArray *evidence_records,const GHashTable *profile_fields) { + if (main_window) workspace_set_person_factual_relations(main_window->workspace, relations, evidence_records,profile_fields); } void main_window_set_quit_callback( diff --git a/src/views/person_creation_confirmation.c b/src/views/person_creation_confirmation.c new file mode 100644 index 0000000..26c79f4 --- /dev/null +++ b/src/views/person_creation_confirmation.c @@ -0,0 +1,65 @@ +#include "views/person_creation_confirmation.h" + +static const char *displayed_value(const IdentityFieldObservation *field) +{ + const char *corrected = identity_field_observation_get_corrected_value(field); + return corrected != NULL ? corrected + : identity_field_observation_get_raw_value(field); +} + +static void append_ocr_review(GString *summary, const GPtrArray *runs) +{ + g_string_append(summary, + "\n\nCorrection OCR enregistrée\n" + "La transcription corrigée reste attachée à la preuve et distincte " + "des données appliquées à la personne. L’OCR peut contenir des " + "erreurs : l’authenticité n’est pas établie."); + if (runs == NULL || runs->len == 0) { + g_string_append(summary, "\nAucune correction OCR enregistrée."); + return; + } + for (guint run_index = 0; run_index < runs->len; run_index++) { + IdentityOcrRun *run = g_ptr_array_index((GPtrArray *) runs, run_index); + g_string_append_printf(summary, + "\n• %s — %s — page %u — Tesseract %s — %s", + identity_ocr_run_get_document_type(run), + identity_ocr_run_get_document_side(run), + identity_ocr_run_get_page(run), + identity_ocr_run_get_version(run) != NULL + ? identity_ocr_run_get_version(run) : "version inconnue", + identity_ocr_run_get_languages(run)); + if (identity_ocr_run_get_factual_notes(run) != NULL) + g_string_append_printf(summary, "\n Notes factuelles : %s", + identity_ocr_run_get_factual_notes(run)); + const GPtrArray *fields = identity_ocr_run_get_fields(run); + for (guint field_index = 0; fields != NULL && field_index < fields->len; + field_index++) { + IdentityFieldObservation *field = + g_ptr_array_index((GPtrArray *) fields, field_index); + IdentityReviewStatus review = + identity_field_observation_get_status(field); + if (review == IDENTITY_REVIEW_ACCEPTED || + review == IDENTITY_REVIEW_MODIFIED) + g_string_append_printf(summary, + "\n - %s : %s (brut : %s ; origine : %s)", + identity_field_observation_get_code(field), + displayed_value(field), + identity_field_observation_get_raw_value(field) != NULL + ? identity_field_observation_get_raw_value(field) + : "absente", + identity_field_observation_get_origin(field)); + } + } +} + +void person_creation_confirmation_append_sections( + GString *summary, const GPtrArray *ocr_runs, + PersonOcrProjectionEditor *projection_editor, + PersonFactualRelationEditor *relation_editor) +{ + if (summary == NULL) + return; + append_ocr_review(summary, ocr_runs); + person_ocr_projection_editor_append_summary(projection_editor, summary); + person_factual_relation_editor_append_summary(relation_editor, summary); +} diff --git a/src/views/person_factual_relation_editor.c b/src/views/person_factual_relation_editor.c index 60afdd1..8ac140c 100644 --- a/src/views/person_factual_relation_editor.c +++ b/src/views/person_factual_relation_editor.c @@ -157,6 +157,7 @@ PersonFactualRelationEditor *person_factual_relation_editor_new(void) PersonFactualRelationEditor *editor=g_new0(PersonFactualRelationEditor,1); editor->rows=g_ptr_array_new_with_free_func(row_free); editor->root=gtk_box_new(GTK_ORIENTATION_VERTICAL,8); + gtk_widget_set_name(editor->root,"person-factual-relation-editor"); GtkWidget *header=gtk_box_new(GTK_ORIENTATION_HORIZONTAL,8); GtkWidget *title=gtk_label_new("Relations factuelles"); gtk_widget_set_hexpand(title,TRUE);gtk_widget_set_halign(title,GTK_ALIGN_START); diff --git a/src/views/person_ocr_projection_editor.c b/src/views/person_ocr_projection_editor.c new file mode 100644 index 0000000..e2158da --- /dev/null +++ b/src/views/person_ocr_projection_editor.c @@ -0,0 +1,197 @@ +#include "views/person_ocr_projection_editor.h" +#include "core/person_ocr_projection_mapping.h" +#include "models/identity_traceability.h" +typedef struct { + GtkCheckButton *selected; + GtkDropDown *target; + GtkStringList *targets; + GtkDropDown *strategy; + IdentityOcrRun *run; + IdentityFieldObservation *field; +} Row; +struct PersonOcrProjectionEditor { + GtkWidget *root; + GtkLabel *title; + GtkBox *rows; + GtkLabel *empty; + GPtrArray *items; +}; +static void row_free(Row *r) { + if (!r) + return; + g_clear_object(&r->targets); + g_free(r); +} +static const char *status_text(IdentityReviewStatus s) { + return s == IDENTITY_REVIEW_ACCEPTED ? "accepted" + : s == IDENTITY_REVIEW_MODIFIED ? "modified" + : s == IDENTITY_REVIEW_REJECTED ? "rejected" + : s == IDENTITY_REVIEW_CONFLICT ? "conflict" + : "proposed"; +} +static void clear(PersonOcrProjectionEditor *e) { + GtkWidget *c = gtk_widget_get_first_child(GTK_WIDGET(e->rows)); + while (c) { + GtkWidget *n = gtk_widget_get_next_sibling(c); + gtk_box_remove(e->rows, c); + c = n; + } + g_ptr_array_set_size(e->items, 0); +} +PersonOcrProjectionEditor *person_ocr_projection_editor_new(void) { + PersonOcrProjectionEditor *e = g_new0(PersonOcrProjectionEditor, 1); + e->root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + gtk_widget_set_name(e->root, "person-ocr-projection-editor"); + e->title = GTK_LABEL(gtk_label_new( + "Appliquer des données OCR à la personne — facultatif")); + gtk_box_append(GTK_BOX(e->root), GTK_WIDGET(e->title)); + e->empty = GTK_LABEL(gtk_label_new( + "Aucune donnée OCR confirmée ne peut être appliquée à cette personne.\n" + "La transcription OCR reste enregistrée dans la preuve.")); + gtk_label_set_wrap(e->empty, TRUE); + gtk_box_append(GTK_BOX(e->root), GTK_WIDGET(e->empty)); + e->rows = GTK_BOX(gtk_box_new(GTK_ORIENTATION_VERTICAL, 8)); + gtk_box_append(GTK_BOX(e->root), GTK_WIDGET(e->rows)); + e->items = g_ptr_array_new_with_free_func((GDestroyNotify)row_free); + return e; +} +GtkWidget * +person_ocr_projection_editor_get_widget(PersonOcrProjectionEditor *e) { + return e ? e->root : NULL; +} +void person_ocr_projection_editor_set_runs(PersonOcrProjectionEditor *e, + const GPtrArray *runs) { + if (!e) + return; + clear(e); + for (guint i = 0; runs && i < runs->len; i++) { + IdentityOcrRun *run = g_ptr_array_index((GPtrArray *)runs, i); + const GPtrArray *fields = identity_ocr_run_get_fields(run); + for (guint j = 0; fields && j < fields->len; j++) { + IdentityFieldObservation *f = g_ptr_array_index((GPtrArray *)fields, j); + const PersonOcrProjectionMapping *m = person_ocr_projection_mapping_for( + identity_field_observation_get_code(f)); + const char *q = identity_field_observation_get_value_quality(f), + *v = identity_field_observation_get_confirmed_value(f); + const char *s = status_text(identity_field_observation_get_status(f)); + if (!m || !identity_traceability_field_is_projectable( + s, q, + identity_field_observation_is_human_confirmed(f) + ? "human_confirmed" + : "unconfirmed", + v)) + continue; + Row *r = g_new0(Row, 1); + r->run = run; + r->field = f; + GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + char *text = g_strdup_printf( + "%s%s\nBrute : %s\nNormalisée : %s\nCorrigée : %s\nConfirmée : " + "%s\nValeur actuelle : —\nRésultat prévisualisé : %s\nQualité : %s — " + "statut : %s\nPreuve : %s — OcrRun : %s", + m->label, g_strcmp0(q, "partial") == 0 ? " — Valeur partielle" : "", + identity_field_observation_get_raw_value(f) + ? identity_field_observation_get_raw_value(f) + : "—", + identity_field_observation_get_normalized_value(f) + ? identity_field_observation_get_normalized_value(f) + : "—", + identity_field_observation_get_corrected_value(f) + ? identity_field_observation_get_corrected_value(f) + : "—", + v, v, q, s, identity_ocr_run_get_evidence_id(run), + identity_ocr_run_get_identifier(run)); + r->selected = GTK_CHECK_BUTTON(gtk_check_button_new_with_label(text)); + gtk_widget_set_name(GTK_WIDGET(r->selected), "projection-select"); + gtk_box_append(GTK_BOX(box), GTK_WIDGET(r->selected)); + g_free(text); + r->targets = gtk_string_list_new(NULL); + gtk_string_list_append(r->targets, "Choisir un champ cible"); + gtk_string_list_append(r->targets, m->label); + r->target = GTK_DROP_DOWN( + gtk_drop_down_new(G_LIST_MODEL(g_object_ref(r->targets)), NULL)); + gtk_widget_set_name(GTK_WIDGET(r->target), "projection-target"); + gtk_box_append(GTK_BOX(box), GTK_WIDGET(r->target)); + static const char *strategies[] = {"Conserver la valeur actuelle", + "Renseigner si vide", + "Remplacer explicitement", NULL}; + r->strategy = GTK_DROP_DOWN(gtk_drop_down_new_from_strings(strategies)); + gtk_widget_set_name(GTK_WIDGET(r->strategy), "projection-strategy"); + gtk_box_append(GTK_BOX(box), GTK_WIDGET(r->strategy)); + gtk_box_append(e->rows, box); + g_ptr_array_add(e->items, r); + } + } + gtk_widget_set_visible(GTK_WIDGET(e->title), e->items->len > 0); + gtk_widget_set_visible(GTK_WIDGET(e->empty), e->items->len == 0); +} +gboolean person_ocr_projection_editor_collect(PersonOcrProjectionEditor *e, + GPtrArray **out, GError **error) { + if (out) + *out = NULL; + if (!e || !out) + return FALSE; + GPtrArray *a = g_ptr_array_new_with_free_func( + (GDestroyNotify)person_ocr_field_projection_free); + for (guint i = 0; i < e->items->len; i++) { + Row *r = g_ptr_array_index(e->items, i); + if (!gtk_check_button_get_active(r->selected)) + continue; + guint target = gtk_drop_down_get_selected(r->target), + strategy = gtk_drop_down_get_selected(r->strategy); + if (target != 1 || strategy == 0) { + g_set_error_literal( + error, g_quark_from_static_string("person-ocr-projection-editor"), 1, + "Choisissez un champ cible et une stratégie d’application."); + g_ptr_array_unref(a); + return FALSE; + } + const PersonOcrProjectionMapping *m = person_ocr_projection_mapping_for( + identity_field_observation_get_code(r->field)); + PersonOcrFieldProjection *p = person_ocr_field_projection_new( + identity_ocr_run_get_evidence_id(r->run), + identity_ocr_run_get_identifier(r->run), + identity_field_observation_get_identifier(r->field), + identity_field_observation_get_code(r->field), + identity_field_observation_get_confirmed_value(r->field), + identity_field_observation_get_value_quality(r->field), + status_text(identity_field_observation_get_status(r->field)), + m->person_field, NULL, + strategy == 1 ? PERSON_OCR_FILL_EMPTY : PERSON_OCR_REPLACE_EXISTING, + TRUE); + if (!p) { + g_set_error_literal( + error, g_quark_from_static_string("person-ocr-projection-editor"), 2, + "La projection préparée est invalide."); + g_ptr_array_unref(a); + return FALSE; + } + g_ptr_array_add(a, p); + } + *out = a; + return TRUE; +} +void person_ocr_projection_editor_append_summary(PersonOcrProjectionEditor *e, + GString *summary) { + if (!e || !summary) + return; + GPtrArray *items = NULL; + g_string_append(summary, "\n\nValeurs OCR choisies pour projection"); + if (!person_ocr_projection_editor_collect(e, &items, NULL)) + return; + if (items->len == 0) + g_string_append(summary, "\nAucune valeur choisie."); + for (guint i = 0; i < items->len; i++) { + PersonOcrFieldProjection *item = g_ptr_array_index(items, i); + g_string_append_printf(summary, "\n• %s : %s", + person_ocr_field_projection_get_person_field(item), + person_ocr_field_projection_get_confirmed_value(item)); + } + g_ptr_array_unref(items); +} +void person_ocr_projection_editor_free(PersonOcrProjectionEditor *e) { + if (!e) + return; + g_ptr_array_unref(e->items); + g_free(e); +} diff --git a/src/widgets/entity_details_panel.c b/src/widgets/entity_details_panel.c index bf5f58d..b4fe791 100644 --- a/src/widgets/entity_details_panel.c +++ b/src/widgets/entity_details_panel.c @@ -42,6 +42,7 @@ struct EntityDetailsPanel GtkWidget *person_evidence_button; GtkWidget *person_evidence_summary_label; GtkWidget *person_factual_relations_box; + GtkWidget *person_profile_fields_box; GtkWidget *entity_evidence_box; GtkWidget *evidence_buttons_box; @@ -889,6 +890,8 @@ EntityDetailsPanel *entity_details_panel_new(void) details_panel->person_evidence_button); gtk_box_append(GTK_BOX(details_box), details_panel->person_role_box); details_panel->person_factual_relations_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + details_panel->person_profile_fields_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + gtk_box_append(GTK_BOX(details_box), details_panel->person_profile_fields_box); gtk_box_append(GTK_BOX(details_box), details_panel->person_factual_relations_box); gtk_box_append(GTK_BOX(details_box), details_panel->entity_evidence_box); g_signal_connect(details_panel->person_role_dropdown, "notify::selected", @@ -972,6 +975,41 @@ EntityDetailsPanel *entity_details_panel_new(void) return details_panel; } +void entity_details_panel_set_person_profile_fields(EntityDetailsPanel *panel, + const GHashTable *fields) +{ + static const struct { const char *code; const char *label; } rows[] = { + {"declared_name", "Nom déclaré"}, {"surname", "Nom"}, + {"given_names", "Prénoms"}, {"birth_date", "Date de naissance"}, + {"birth_place", "Lieu de naissance"}, {"nationality", "Nationalité"}, + {"sex_as_printed", "Sexe imprimé"}, + {"address_as_printed", "Adresse imprimée"} + }; + if (panel == NULL || panel->person_profile_fields_box == NULL) return; + GtkWidget *child = NULL; + while ((child = gtk_widget_get_first_child(panel->person_profile_fields_box))) + gtk_box_remove(GTK_BOX(panel->person_profile_fields_box), child); + if (fields == NULL || g_hash_table_size((GHashTable *)fields) == 0) { + gtk_widget_set_visible(panel->person_profile_fields_box, FALSE); + return; + } + GtkWidget *title = gtk_label_new("Valeurs confirmées du profil"); + gtk_label_set_xalign(GTK_LABEL(title), 0.0F); + gtk_box_append(GTK_BOX(panel->person_profile_fields_box), title); + for (guint i = 0; i < G_N_ELEMENTS(rows); i++) { + const char *value = g_hash_table_lookup((GHashTable *)fields, rows[i].code); + if (value == NULL) continue; + char *text = g_strdup_printf("%s : %s", rows[i].label, value); + GtkWidget *label = gtk_label_new(text); + gtk_label_set_xalign(GTK_LABEL(label), 0.0F); + gtk_label_set_wrap(GTK_LABEL(label), TRUE); + gtk_label_set_selectable(GTK_LABEL(label), TRUE); + gtk_box_append(GTK_BOX(panel->person_profile_fields_box), label); + g_free(text); + } + gtk_widget_set_visible(panel->person_profile_fields_box, TRUE); +} + GtkWidget *entity_details_panel_get_widget( const EntityDetailsPanel *details_panel ) diff --git a/src/widgets/workspace.c b/src/widgets/workspace.c index 2d8a7dc..94c7c1e 100644 --- a/src/widgets/workspace.c +++ b/src/widgets/workspace.c @@ -3914,8 +3914,8 @@ void workspace_set_entity_selected_callback(Workspace *workspace, void workspace_set_person_evidences(Workspace *workspace, const GPtrArray *records) { if (workspace) entity_details_panel_set_person_evidences(workspace->entity_details_panel, records); } -void workspace_set_person_factual_relations(Workspace *workspace, const GPtrArray *relations, const GPtrArray *evidence_records) { - if (workspace) entity_details_panel_set_person_factual_relations(workspace->entity_details_panel, relations, evidence_records); +void workspace_set_person_factual_relations(Workspace *workspace, const GPtrArray *relations, const GPtrArray *evidence_records,const GHashTable *profile_fields) { + if (workspace) { entity_details_panel_set_person_factual_relations(workspace->entity_details_panel, relations, evidence_records);entity_details_panel_set_person_profile_fields(workspace->entity_details_panel,profile_fields); } } void workspace_reset_graph_layout( diff --git a/tests/test_create_person_dialog_gtk.c b/tests/test_create_person_dialog_gtk.c index cac8928..a6d2eb0 100644 --- a/tests/test_create_person_dialog_gtk.c +++ b/tests/test_create_person_dialog_gtk.c @@ -82,6 +82,37 @@ static GtkWidget *find_named(GtkWidget *widget, const char *name) return NULL; } +static guint count_label(GtkWidget *widget, const char *text) +{ + guint count = GTK_IS_LABEL(widget) && + g_strcmp0(gtk_label_get_text(GTK_LABEL(widget)), text) == 0 ? 1 : 0; + for (GtkWidget *child = gtk_widget_get_first_child(widget); + child != NULL; child = gtk_widget_get_next_sibling(child)) + count += count_label(child, text); + return count; +} + +static void assert_assistant_steps(GtkWidget *dialog) +{ + static const char *const names[] = {"person", "roles", "evidence", + "identity-ocr", "ocr-projection", "factual-relations", "summary"}; + static const char *const titles[] = {"1 — Personne", "2 — Rôles", + "3 — Preuves", "4 — OCR identité", "5 — Projection OCR", + "6 — Relations factuelles", "7 — Confirmation"}; + GtkStack *stack = GTK_STACK(find_named(dialog, + "create-person-assistant-stack")); + g_assert_nonnull(stack); + GtkSelectionModel *selection = gtk_stack_get_pages(stack); + GListModel *pages = G_LIST_MODEL(selection); + g_assert_cmpuint(g_list_model_get_n_items(pages), ==, G_N_ELEMENTS(names)); + for (guint i = 0; i < G_N_ELEMENTS(names); i++) { + GtkStackPage *page = g_list_model_get_item(pages, i); + g_assert_cmpstr(gtk_stack_page_get_name(page), ==, names[i]); + g_assert_cmpstr(gtk_stack_page_get_title(page), ==, titles[i]); + g_object_unref(page); + } +} + static gboolean frame_seen(GtkWidget *widget, GdkFrameClock *clock, gpointer data) { @@ -163,6 +194,7 @@ static gboolean click_cancel(gpointer data) context->main_window); g_assert_true(gtk_window_get_modal(dialog)); g_assert_true(gtk_window_get_destroy_with_parent(dialog)); + assert_assistant_steps(GTK_WIDGET(dialog)); gtk_window_set_default_size(dialog, 760, 560); wait_for_frame(GTK_WIDGET(dialog)); designation = find_entry(GTK_WIDGET(dialog), @@ -221,12 +253,35 @@ static gboolean click_cancel(gpointer data) g_signal_emit_by_name(button, "clicked"); g_assert_nonnull(find_label_containing(GTK_WIDGET(dialog), "L’OCR produit des propositions à vérifier")); + g_assert_nonnull(find_named(GTK_WIDGET(dialog), + "create-person-ocr-transcription-stack")); + g_signal_emit_by_name(button, "clicked"); + GtkStack *stack = GTK_STACK(find_named(GTK_WIDGET(dialog), + "create-person-assistant-stack")); + g_assert_true(gtk_widget_is_ancestor( + find_named(GTK_WIDGET(dialog), "person-ocr-projection-editor"), + gtk_stack_get_visible_child(stack))); + const char *empty_projection = + "Aucune donnée OCR confirmée ne peut être appliquée à cette personne.\n" + "La transcription OCR reste enregistrée dans la preuve."; + g_assert_cmpuint(count_label(GTK_WIDGET(dialog), empty_projection), ==, 1); + g_assert_cmpuint(count_label(GTK_WIDGET(dialog), + "Valeurs OCR confirmées à appliquer explicitement"), ==, 0); g_signal_emit_by_name(button, "clicked"); g_assert_nonnull(find_label_containing(GTK_WIDGET(dialog), "Relations factuelles")); + g_assert_true(gtk_widget_is_ancestor( + find_named(GTK_WIDGET(dialog), "person-factual-relation-editor"), + gtk_stack_get_visible_child(stack))); g_signal_emit_by_name(button, "clicked"); g_assert_nonnull(find_label_containing(GTK_WIDGET(dialog), "Désignation : SPECIMEN")); + g_assert_nonnull(find_label_containing(GTK_WIDGET(dialog), + "Correction OCR enregistrée")); + g_assert_nonnull(find_label_containing(GTK_WIDGET(dialog), + "Valeurs OCR choisies pour projection")); + g_assert_nonnull(find_label_containing(GTK_WIDGET(dialog), + "Relations factuelles préparées")); g_assert_nonnull(find_label_containing(GTK_WIDGET(dialog), "SPECIMEN-recherche.png")); g_assert_nonnull(find_label_containing(GTK_WIDGET(dialog), diff --git a/tests/test_create_person_dialog_ocr_gtk.c b/tests/test_create_person_dialog_ocr_gtk.c index 16a1f82..857cd3a 100644 --- a/tests/test_create_person_dialog_ocr_gtk.c +++ b/tests/test_create_person_dialog_ocr_gtk.c @@ -570,8 +570,15 @@ static gboolean drive(gpointer data) context->confirmation_height=gtk_widget_get_height( GTK_WIDGET(context->dialog)); g_signal_emit_by_name(find_button(root,"Suivant"),"clicked"); + g_assert_nonnull(find_label(root, + "Appliquer des données OCR à la personne — facultatif")); + g_signal_emit_by_name(find_button(root,"Suivant"),"clicked"); g_assert_nonnull(find_label(root,"Relations factuelles")); g_signal_emit_by_name(find_button(root,"Suivant"),"clicked"); + g_assert_nonnull(find_label(root,"Correction OCR enregistrée")); + g_assert_nonnull(find_label(root, + "Valeurs OCR choisies pour projection")); + g_assert_nonnull(find_label(root,"Relations factuelles préparées")); g_assert_nonnull(find_label(root,"page 2")); g_assert_nonnull(find_label(root,"PAGE 2 VALEUR FINALE")); g_assert_nonnull(find_label(root,"authenticité n’est pas établie")); diff --git a/tests/test_database.c b/tests/test_database.c index 9f68738..695d619 100644 --- a/tests/test_database.c +++ b/tests/test_database.c @@ -525,7 +525,7 @@ static void test_database_initialize_valid_database(void) "FROM investigation;" ); - assert(strcmp(schema_version, "18") == 0); + assert(strcmp(schema_version, "19") == 0); test_database_assert_table_exists(database, "person_role_assignments"); test_database_assert_table_exists(database, "bank_account_entities"); test_database_assert_table_exists(database, "relation_types"); @@ -1001,7 +1001,7 @@ static void test_database_migrate_v1_to_v2(void) assert( strcmp( schema_version, - "18" + "19" ) == 0 ); @@ -1430,6 +1430,8 @@ static void test_database_migrate_v12_to_v13_preserves_legacy_link(void) "INSERT INTO person_roles(entity_id,role,updated_at) VALUES(" "'20000000-0000-4000-8000-000000000014','alleged_scammer'," "'2026-07-28T08:00:00Z');" + "DROP TABLE person_ocr_field_projections;" + "DROP TABLE person_profile_fields;" "DROP TABLE person_identification_assessments;" "DROP TABLE person_evidence_factual_relations;" "DROP TABLE document_authenticity_assessments;" @@ -1464,7 +1466,7 @@ static void test_database_migrate_v12_to_v13_preserves_legacy_link(void) legacy_sources = test_database_read_single_text(sqlite_database, "SELECT COUNT(*) FROM preuve_entite_sources " "WHERE source_kind='legacy_manual';"); - assert(strcmp(version, "18") == 0); + assert(strcmp(version, "19") == 0); assert(strcmp(legacy_sources, "1") == 0); char *legacy_role = test_database_read_single_text(sqlite_database, "SELECT role_code || ':' || provenance_kind " diff --git a/tests/test_identity_traceability.c b/tests/test_identity_traceability.c index bb561b6..8298498 100644 --- a/tests/test_identity_traceability.c +++ b/tests/test_identity_traceability.c @@ -218,6 +218,7 @@ static void test_migrate_v17_preserves_ocr(void) Fixture f=fixture_new();database_close(f.database);f.database=NULL; sqlite3*d=NULL;g_assert_cmpint(sqlite3_open(f.path,&d),==,SQLITE_OK); exec_ok(d,"PRAGMA foreign_keys=OFF;" + "DROP TABLE person_ocr_field_projections;DROP TABLE person_profile_fields;" "DROP TABLE person_identification_assessments;" "DROP TABLE person_evidence_factual_relations;" "DROP TABLE document_authenticity_assessments;" @@ -257,7 +258,7 @@ static void test_migrate_v17_preserves_ocr(void) char*version=scalar(d,"SELECT value FROM metadata WHERE key='schema_version';"); char*values=scalar(d,"SELECT raw_value||':'||normalized_value||':'||corrected_value" "||':'||confirmation_state FROM identity_field_observations WHERE id='" FIELD "';"); - g_assert_cmpstr(version,==,"18"); + g_assert_cmpstr(version,==,"19"); g_assert_cmpstr(values,==,"BRUT SPECIMEN:BRUT SPECIMEN:CORRIGÉ SPECIMEN:unconfirmed"); sqlite3_close(d);g_free(version);g_free(values); f.database=database_open(f.path);g_assert_true(database_migrate_to_latest(f.database)); diff --git a/tests/test_person_creation_coordinator.c b/tests/test_person_creation_coordinator.c index 98d053c..ec70ca4 100644 --- a/tests/test_person_creation_coordinator.c +++ b/tests/test_person_creation_coordinator.c @@ -57,7 +57,8 @@ static void assert_empty_after_reopen(const char *database_path) "entites", "preuves", "preuve_entites", "preuve_entite_sources", "person_role_assignments", "identity_ocr_runs", "identity_document_observations", "identity_field_observations", - "person_evidence_factual_relations" + "person_evidence_factual_relations", "person_profile_fields", + "person_ocr_field_projections" }; Database *database = database_open(database_path); g_assert_nonnull(database); @@ -102,6 +103,7 @@ static void test_failure_matrix(void) {PERSON_CREATION_FAILURE_CREATE_SOURCE,1}, {PERSON_CREATION_FAILURE_CREATE_SOURCE,2}, {PERSON_CREATION_FAILURE_INSERT_FACTUAL_RELATION,0}, + {PERSON_CREATION_FAILURE_APPLY_OCR_PROJECTION,0}, {PERSON_CREATION_FAILURE_SESSION_BEFORE_COMMIT,0}, {PERSON_CREATION_FAILURE_ARTIFACT_TEXT_CHANGED,0}, {PERSON_CREATION_FAILURE_ARTIFACT_TSV_CHANGED,0}, @@ -152,6 +154,7 @@ static void test_failure_matrix(void) identity_field_observation_new("surname", "SPECIMEN", 90, NULL, i); identity_field_observation_accept(field); + identity_field_observation_confirm(field,"SPECIMEN"); identity_ocr_run_add_field(run, field); g_ptr_array_add(runs, run); PersonRoleAssignmentInput role = { @@ -180,12 +183,23 @@ static void test_failure_matrix(void) }; GPtrArray *factual_relations = g_ptr_array_new(); g_ptr_array_add(factual_relations, &factual_relation); + IdentityOcrRun *projection_run=g_ptr_array_index(runs,0); + IdentityFieldObservation *projection_field=g_ptr_array_index( + (GPtrArray*)identity_ocr_run_get_fields(projection_run),0); + PersonOcrFieldProjection *projection=person_ocr_field_projection_new( + identity_ocr_run_get_evidence_id(projection_run), + identity_ocr_run_get_identifier(projection_run), + identity_field_observation_get_identifier(projection_field), + "surname","SPECIMEN","complete","accepted","surname",NULL, + PERSON_OCR_FILL_EMPTY,TRUE); + GPtrArray *projections=g_ptr_array_new();g_ptr_array_add(projections,projection); PersonCreationCoordinatorOptions options = { .failure_point = cases[scenario].point, .failure_occurrence = cases[scenario].occurrence, .inject_compensation_failure = scenario == G_N_ELEMENTS(cases) - 1, .factual_relations = factual_relations + ,.ocr_projections = projections }; char *ocr_parent=g_build_filename(root,"02_Preuves_Traitees", "OCR",NULL); @@ -238,6 +252,7 @@ static void test_failure_matrix(void) } g_ptr_array_unref(roles); g_ptr_array_unref(factual_relations); + g_ptr_array_unref(projections);person_ocr_field_projection_free(projection); g_ptr_array_unref(runs); person_evidence_selection_free(selection); g_ptr_array_unref(prepared); diff --git a/tests/test_person_ocr_projection.c b/tests/test_person_ocr_projection.c new file mode 100644 index 0000000..ed769b3 --- /dev/null +++ b/tests/test_person_ocr_projection.c @@ -0,0 +1,251 @@ +#include "core/person_ocr_projection_mapping.h" +#include "core/person_ocr_projection_service.h" +#include "dao/person_ocr_projection_dao.h" +#include "database/database.h" +#include +#include +#define PERSON "20000000-0000-4000-8000-000000000019" +#define EVIDENCE "10000000-0000-4000-8000-000000000019" +#define RUN "30000000-0000-4000-8000-000000000019" +#define DOC "40000000-0000-4000-8000-000000000019" +#define FIELD "50000000-0000-4000-8000-000000000019" +#define FIELD2 "50000000-0000-4000-8000-000000000020" +#define AT "2026-08-01T10:00:00Z" +typedef struct { + char *dir, *path; + Database *d; +} Fixture; +static void exec_ok(sqlite3 *d, const char *s) { + char *m = NULL; + int rc = sqlite3_exec(d, s, NULL, NULL, &m); + if (rc != SQLITE_OK) + g_test_message("SQLite: %s", m); + g_assert_cmpint(rc, ==, SQLITE_OK); + sqlite3_free(m); +} +static Fixture fixture(void) { + Fixture f = {0}; + f.dir = g_dir_make_tmp("labfy-projection-XXXXXX", NULL); + f.path = g_build_filename(f.dir, "SPECIMEN.sqlite", NULL); + g_assert_true(database_initialize(f.path, "SPECIMEN", f.dir)); + sqlite3 *s = NULL; + sqlite3_open(f.path, &s); + exec_ok( + s, + "INSERT INTO " + "preuves(id,name,relative_path,type_id,size_bytes,sha256,imported_at," + "updated_at,status,locked,original_name) VALUES('" EVIDENCE + "','SPECIMEN','SPECIMEN',2,8,'" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa','" AT + "','" AT "','active',0,'SPECIMEN');INSERT INTO " + "entites(id,type_id,valeur,label,confiance,created_at,updated_" + "at,status) VALUES('" PERSON + "',(SELECT id FROM types_entite WHERE code='person'),'PERSONNE " + "SPECIMEN','PERSONNE SPECIMEN',0,'" AT "','" AT + "','active');INSERT INTO " + "identity_ocr_runs(id,evidence_id,expected_sha256,page_number,document_" + "type,document_side,engine,requested_languages,available_languages," + "parameters,preprocessing_profile,executed_at,status,text_relative_path," + "text_sha256,tsv_relative_path,tsv_sha256) VALUES('" RUN "','" EVIDENCE + "','aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',1,'" + "identity_card','front','SPECIMEN','fra','fra','SPECIMEN','none','" AT + "','success','SPECIMEN.txt','" + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb','" + "SPECIMEN.tsv','" + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc');" + "INSERT INTO " + "identity_document_observations(id,person_id,evidence_id,ocr_run_id," + "document_type,document_side,page_number,review_state,observed_at) " + "VALUES('" DOC "','" PERSON "','" EVIDENCE "','" RUN + "','identity_card','front',1,'accepted','" AT + "');INSERT INTO " + "identity_field_observations(id,observation_id,field_code,raw_value," + "normalized_value,review_status,origin,evidence_id,ocr_run_id,page_" + "number,display_order,reviewed_at,confirmed_value,confirmation_state," + "value_quality) VALUES('" FIELD "','" DOC + "','surname','BRUT','NORMALISÉ','accepted','ocr','" EVIDENCE "','" RUN + "',1,0,'" AT "','NOM SPECIMEN','human_confirmed','complete'),('" FIELD2 + "','" DOC "','given_names','BRUT " + "PARTIEL','PARTIEL','modified','manual_override','" EVIDENCE + "','" RUN "',1,1,'" AT + "','PRÉNOM SPECIMEN','human_confirmed','partial');"); + sqlite3_close(s); + f.d = database_open(f.path); + g_assert_true(database_migrate_to_latest(f.d)); + return f; +} +static void done(Fixture *f) { + database_close(f->d); + g_remove(f->path); + g_rmdir(f->dir); + g_free(f->path); + g_free(f->dir); +} +static PersonOcrFieldProjection * +projection(const char *field, const char *code, const char *value, + const char *q, const char *target, const char *current, + PersonOcrProjectionStrategy strategy) { + return person_ocr_field_projection_new( + EVIDENCE, RUN, field, code, value, q, + g_strcmp0(q, "partial") == 0 ? "modified" : "accepted", target, current, + strategy, TRUE); +} +static void test_model_mapping(void) { + PersonOcrFieldProjection *p = + projection(FIELD, "surname", "NOM SPECIMEN", "complete", "surname", NULL, + PERSON_OCR_FILL_EMPTY); + g_assert_nonnull(p); + PersonOcrFieldProjection *c = person_ocr_field_projection_copy(p); + g_assert_cmpstr(person_ocr_field_projection_get_confirmed_value(c), ==, + "NOM SPECIMEN"); + g_assert_true( + person_ocr_projection_mapping_is_compatible("surname", "surname")); + g_assert_false(person_ocr_projection_mapping_is_compatible("document_number", + "surname")); + g_assert_null(person_ocr_field_projection_new( + EVIDENCE, RUN, FIELD, "surname", "", "complete", "accepted", "surname", + NULL, PERSON_OCR_FILL_EMPTY, TRUE)); + g_assert_null(person_ocr_field_projection_new( + EVIDENCE, RUN, FIELD, "surname", "NOM", NULL, "accepted", "surname", + NULL, PERSON_OCR_FILL_EMPTY, TRUE)); + g_assert_null(person_ocr_field_projection_new( + EVIDENCE, RUN, FIELD, "surname", "NOM", "uncertain", "accepted", + "surname", NULL, PERSON_OCR_FILL_EMPTY, TRUE)); + g_assert_null(person_ocr_field_projection_new( + EVIDENCE, RUN, FIELD, "surname", "NOM", "complete", "rejected", + "surname", NULL, PERSON_OCR_FILL_EMPTY, TRUE)); + char invalid_utf8[] = {(char)0xff, 0}; + g_assert_null(person_ocr_field_projection_new( + EVIDENCE, RUN, FIELD, "surname", invalid_utf8, "complete", "accepted", + "surname", NULL, PERSON_OCR_FILL_EMPTY, TRUE)); + person_ocr_field_projection_free(c); + person_ocr_field_projection_free(p); +} +static void test_conflicts_and_foreign_sources(void) { + Fixture f = fixture(); + GError *e = NULL; + sqlite3 *s = NULL; + database_close(f.d); + f.d = NULL; + sqlite3_open(f.path, &s); + exec_ok(s, "INSERT INTO person_profile_fields VALUES('" PERSON + "','surname','ANCIEN SPECIMEN','" AT "');"); + sqlite3_close(s); + f.d = database_open(f.path); + GPtrArray *a = g_ptr_array_new_with_free_func( + (GDestroyNotify)person_ocr_field_projection_free); + g_ptr_array_add(a, projection(FIELD, "surname", "NOM SPECIMEN", "complete", + "surname", "ANCIEN SPECIMEN", + PERSON_OCR_KEEP_EXISTING)); + g_assert_true(person_ocr_projection_service_apply(f.d, PERSON, a, &e)); + PersonOcrProjectionDao *dao = person_ocr_projection_dao_new(f.d); + char *value = person_ocr_projection_dao_get_value(dao, PERSON, "surname", &e); + g_assert_cmpstr(value, ==, "ANCIEN SPECIMEN"); + g_free(value); + g_ptr_array_set_size(a, 0); + g_ptr_array_add(a, projection(FIELD, "surname", "NOM SPECIMEN", "complete", + "surname", "ANCIEN SPECIMEN", + PERSON_OCR_REPLACE_EXISTING)); + g_assert_true(person_ocr_projection_service_apply(f.d, PERSON, a, &e)); + GHashTable *fields = person_ocr_projection_dao_list_profile_fields( + dao, PERSON, &e); + g_assert_no_error(e); + g_assert_cmpstr(g_hash_table_lookup(fields, "surname"), ==, "NOM SPECIMEN"); + g_hash_table_unref(fields); + g_ptr_array_set_size(a, 0); + g_ptr_array_add(a, person_ocr_field_projection_new( + EVIDENCE, "30000000-0000-4000-8000-000000000099", + FIELD, "surname", "NOM SPECIMEN", "complete", + "accepted", "surname", "NOM SPECIMEN", + PERSON_OCR_REPLACE_EXISTING, TRUE)); + g_assert_false(person_ocr_projection_service_apply(f.d, PERSON, a, &e)); + g_assert_nonnull(e); + g_clear_error(&e); + person_ocr_projection_dao_free(dao); + g_ptr_array_unref(a); + done(&f); +} +static void test_mid_series_rollback(void) { + Fixture f = fixture(); + GError *e = NULL; + GPtrArray *a = g_ptr_array_new_with_free_func( + (GDestroyNotify)person_ocr_field_projection_free); + g_ptr_array_add(a, projection(FIELD2, "given_names", "PRÉNOM SPECIMEN", + "partial", "given_names", NULL, + PERSON_OCR_FILL_EMPTY)); + g_ptr_array_add(a, person_ocr_field_projection_new( + EVIDENCE, RUN, FIELD, "surname", "VALEUR OBSOLÈTE", + "complete", "accepted", "surname", NULL, + PERSON_OCR_FILL_EMPTY, TRUE)); + g_assert_false(person_ocr_projection_service_apply(f.d, PERSON, a, &e)); + g_assert_nonnull(e); + g_clear_error(&e); + PersonOcrProjectionDao *dao = person_ocr_projection_dao_new(f.d); + char *value = person_ocr_projection_dao_get_value( + dao, PERSON, "given_names", &e); + g_assert_null(value); + GPtrArray *history = person_ocr_projection_dao_list(dao, PERSON, &e); + g_assert_cmpuint(history->len, ==, 0); + g_ptr_array_unref(history); + person_ocr_projection_dao_free(dao); + g_ptr_array_unref(a); + done(&f); +} +static void test_apply_stale_rollback(void) { + Fixture f = fixture(); + GError *e = NULL; + GPtrArray *candidates = + person_ocr_projection_service_candidates(f.d, EVIDENCE, RUN, &e); + g_assert_no_error(e); + g_assert_cmpuint(candidates->len, ==, 2); + g_ptr_array_unref(candidates); + GPtrArray *a = g_ptr_array_new_with_free_func( + (GDestroyNotify)person_ocr_field_projection_free); + g_ptr_array_add(a, projection(FIELD, "surname", "NOM SPECIMEN", "complete", + "surname", NULL, PERSON_OCR_FILL_EMPTY)); + g_ptr_array_add(a, projection(FIELD2, "given_names", "PRÉNOM SPECIMEN", + "partial", "given_names", NULL, + PERSON_OCR_FILL_EMPTY)); + g_assert_true(person_ocr_projection_service_apply(f.d, PERSON, a, &e)); + PersonOcrProjectionDao *dao = person_ocr_projection_dao_new(f.d); + char *v = person_ocr_projection_dao_get_value(dao, PERSON, "surname", &e); + g_assert_cmpstr(v, ==, "NOM SPECIMEN"); + g_free(v); + GPtrArray *h = person_ocr_projection_dao_list(dao, PERSON, &e); + g_assert_cmpuint(h->len, ==, 2); + g_ptr_array_unref(h); + sqlite3 *s = NULL; + database_close(f.d); + f.d = NULL; + sqlite3_open(f.path, &s); + exec_ok(s, "UPDATE identity_field_observations SET " + "review_status='rejected',confirmed_value=NULL,confirmation_state=" + "'unconfirmed' WHERE id='" FIELD "';"); + sqlite3_close(s); + f.d = database_open(f.path); + g_ptr_array_set_size(a, 0); + g_ptr_array_add(a, projection(FIELD, "surname", "NOM SPECIMEN", "complete", + "surname", "NOM SPECIMEN", + PERSON_OCR_REPLACE_EXISTING)); + g_assert_false(person_ocr_projection_service_apply(f.d, PERSON, a, &e)); + g_assert_nonnull(e); + g_clear_error(&e); + person_ocr_projection_dao_free(dao); + dao = person_ocr_projection_dao_new(f.d); + h = person_ocr_projection_dao_list(dao, PERSON, &e); + g_assert_cmpuint(h->len, ==, 2); + g_ptr_array_unref(h); + person_ocr_projection_dao_free(dao); + g_ptr_array_unref(a); + done(&f); +} +int main(int argc, char **argv) { + g_test_init(&argc, &argv, NULL); + g_test_add_func("/projection/model-mapping", test_model_mapping); + g_test_add_func("/projection/apply-stale-rollback", + test_apply_stale_rollback); + g_test_add_func("/projection/conflicts-foreign-sources", + test_conflicts_and_foreign_sources); + g_test_add_func("/projection/mid-series-rollback", test_mid_series_rollback); + return g_test_run(); +} diff --git a/tests/test_person_ocr_projection_editor_gtk.c b/tests/test_person_ocr_projection_editor_gtk.c new file mode 100644 index 0000000..287d537 --- /dev/null +++ b/tests/test_person_ocr_projection_editor_gtk.c @@ -0,0 +1,115 @@ +#include "views/person_ocr_projection_editor.h" +#include +#define EVIDENCE "10000000-0000-4000-8000-000000000019" +static GtkWidget *named(GtkWidget *w, const char *n) { + if (g_strcmp0(gtk_widget_get_name(w), n) == 0) + return w; + for (GtkWidget *c = gtk_widget_get_first_child(w); c; + c = gtk_widget_get_next_sibling(c)) { + GtkWidget *f = named(c, n); + if (f) + return f; + } + return NULL; +} +static guint count_named(GtkWidget *w, const char *name) { + guint count = g_strcmp0(gtk_widget_get_name(w), name) == 0 ? 1 : 0; + for (GtkWidget *child = gtk_widget_get_first_child(w); child; + child = gtk_widget_get_next_sibling(child)) + count += count_named(child, name); + return count; +} +static GtkWidget *label(GtkWidget *w, const char *text) { + if (GTK_IS_LABEL(w) && g_strcmp0(gtk_label_get_text(GTK_LABEL(w)), text) == 0) + return w; + for (GtkWidget *c = gtk_widget_get_first_child(w); c; + c = gtk_widget_get_next_sibling(c)) { + GtkWidget *found = label(c, text); + if (found) + return found; + } + return NULL; +} +static void test_editor(void) { + PersonOcrProjectionEditor *e = person_ocr_projection_editor_new(); + GtkWidget *root = person_ocr_projection_editor_get_widget(e); + g_object_ref_sink(root); + person_ocr_projection_editor_set_runs(e, NULL); + GtkWidget *empty = label(root, + "Aucune donnée OCR confirmée ne peut être appliquée à cette personne.\n" + "La transcription OCR reste enregistrée dans la preuve."); + GtkWidget *title = label(root, + "Appliquer des données OCR à la personne — facultatif"); + g_assert_nonnull(empty); + g_assert_nonnull(title); + g_assert_true(gtk_widget_get_visible(empty)); + g_assert_false(gtk_widget_get_visible(title)); + GPtrArray *out = NULL; + GError *error = NULL; + g_assert_true(person_ocr_projection_editor_collect(e, &out, &error)); + g_assert_cmpuint(out->len, ==, 0); + g_ptr_array_unref(out); + IdentityOcrRun *r = identity_ocr_run_new( + EVIDENCE, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "identity_card", "front", 1, "fra", "none"); + IdentityFieldObservation *f = + identity_field_observation_new("surname", "BRUT SPECIMEN", 90, NULL, 0); + identity_field_observation_accept(f); + identity_field_observation_set_normalized_value(f, "NORMALISÉ SPECIMEN"); + identity_field_observation_confirm(f, "NOM SPECIMEN"); + identity_ocr_run_add_field(r, f); + IdentityFieldObservation *partial = identity_field_observation_new( + "given_names", "BRUT PARTIEL", 72, NULL, 1); + identity_field_observation_modify(partial, "CORRIGÉ PARTIEL", NULL); + identity_field_observation_set_value_quality(partial, "partial"); + identity_field_observation_confirm(partial, "PRÉNOM SPECIMEN"); + identity_ocr_run_add_field(r, partial); + IdentityFieldObservation *rejected = identity_field_observation_new( + "nationality", "REJETÉ SPECIMEN", 40, NULL, 2); + identity_field_observation_reject(rejected); + identity_ocr_run_add_field(r, rejected); + IdentityFieldObservation *unmapped = identity_field_observation_new( + "document_number", "DOCUMENT SPECIMEN", 95, NULL, 3); + identity_field_observation_accept(unmapped); + identity_field_observation_confirm(unmapped, "DOCUMENT SPECIMEN"); + identity_ocr_run_add_field(r, unmapped); + GPtrArray *runs = g_ptr_array_new(); + g_ptr_array_add(runs, r); + person_ocr_projection_editor_set_runs(e, runs); + g_assert_false(gtk_widget_get_visible(empty)); + g_assert_true(gtk_widget_get_visible(title)); + g_assert_cmpuint(count_named(root, "projection-select"), ==, 2); + GtkCheckButton *select = GTK_CHECK_BUTTON(named(root, "projection-select")); + g_assert_false(gtk_check_button_get_active(select)); + gtk_check_button_set_active(select, TRUE); + g_assert_false(person_ocr_projection_editor_collect(e, &out, &error)); + g_assert_nonnull(error); + g_assert_true(gtk_check_button_get_active(select)); + g_clear_error(&error); + GtkDropDown *target = GTK_DROP_DOWN(named(root, "projection-target")); + GtkDropDown *strategy = GTK_DROP_DOWN(named(root, "projection-strategy")); + gtk_drop_down_set_selected(target, 1); + gtk_drop_down_set_selected(strategy, 1); + g_assert_true(person_ocr_projection_editor_collect(e, &out, &error)); + g_assert_cmpuint(out->len, ==, 1); + g_ptr_array_unref(out); + GString *summary = g_string_new(NULL); + person_ocr_projection_editor_append_summary(e, summary); + g_assert_nonnull(strstr(summary->str, + "Valeurs OCR choisies pour projection")); + g_assert_nonnull(strstr(summary->str, "surname : NOM SPECIMEN")); + g_string_free(summary, TRUE); + person_ocr_projection_editor_set_runs(e, NULL); + g_assert_cmpuint(count_named(root, "projection-select"), ==, 0); + g_ptr_array_unref(runs); + identity_ocr_run_free(r); + g_object_unref(root); + person_ocr_projection_editor_free(e); +} +int main(int argc, char **argv) { + gtk_init(); + g_test_init(&argc, &argv, NULL); + g_test_add_func("/projection/editor", test_editor); + return g_test_run(); +} diff --git a/ticket109.md b/ticket109.md new file mode 100644 index 0000000..fd257e6 --- /dev/null +++ b/ticket109.md @@ -0,0 +1,473 @@ +## Contexte + +Lors de la création d’une personne dans une enquête, l’utilisateur doit +actuellement renseigner les données manuellement et ne peut pas choisir +clairement la nature de la personne ni associer facilement une preuve. + +Le parcours devient particulièrement long lorsque les noms de fichiers +ne permettent pas de savoir ce qu’ils contiennent. + +Le cas des documents d’identité nécessite également un traitement +forensique strict : + +- conservation de l’original ; +- analyse sur une copie de travail ; +- OCR contrôlé ; +- validation humaine ; +- distinction entre texte brut, valeur normalisée et correction manuelle ; +- aucune affirmation automatique concernant l’identité réelle ou + l’authenticité du document. + +## Objectif + +Créer un assistant fluide permettant, depuis la fenêtre de création d’une +personne : + +1. de sélectionner son rôle ou sa nature dans l’enquête ; +2. de choisir une preuve déjà importée ; +3. d’importer immédiatement une nouvelle preuve ; +4. de visualiser la preuve avant de la sélectionner ; +5. de qualifier le type de preuve ; +6. de lancer une analyse adaptée au type choisi ; +7. d’utiliser l’OCR pour préremplir les champs d’un document d’identité ; +8. de corriger manuellement les données proposées ; +9. de conserver la provenance complète de chaque valeur ; +10. de garantir l’intégrité du fichier original. + +## Terminologie + +Ne pas confondre : + +- le rôle de la personne dans l’enquête ; +- son niveau d’identification ; +- l’authenticité du document présenté ; +- la confiance accordée aux différentes observations. + +Une personne peut par exemple avoir : + +- rôle : Identité présentée ; +- identification : Non vérifiée ; +- document : Authenticité indéterminée ; +- hypothèse : Identité potentiellement usurpée. + +L’application ne doit jamais transformer automatiquement cette personne +en auteur identifié. + +## Fenêtre de création d’une personne + +Ajouter un champ contrôlé : + + Rôle dans l’enquête + +Valeurs initiales proposées : + +- Auteur présumé ; +- Identité présentée ; +- Identité potentiellement usurpée ; +- Victime ; +- Témoin ; +- Titulaire bancaire déclaré ; +- Intermédiaire ; +- Personne citée ; +- Autre. + +Ces valeurs doivent provenir du vocabulaire contrôlé du projet. + +Le champ existant « Identification » reste distinct et conserve des états +tels que : + +- Inconnu ; +- Non vérifié ; +- Partiellement identifié ; +- Identifié ; +- Contesté. + +## Association d’une preuve + +Depuis la fenêtre de création d’une personne, proposer deux actions : + +- Sélectionner une preuve existante ; +- Importer une nouvelle preuve. + +L’utilisateur ne doit pas être obligé de fermer la fenêtre, importer la +preuve ailleurs, puis recommencer la création de la personne. + +### Preuve existante + +La sélection doit afficher au minimum : + +- miniature ou aperçu ; +- nom du fichier ; +- type de preuve ; +- taille ; +- date d’import ; +- empreinte SHA-256 abrégée ; +- description éventuelle ; +- état d’intégrité. + +Prévoir une recherche et un filtrage par type. + +### Nouvelle preuve + +L’import déclenché depuis la fenêtre doit utiliser le mécanisme forensique +central du projet : + +- copie contrôlée dans l’enquête ; +- calcul du SHA-256 ; +- conservation du nom original ; +- enregistrement de la provenance ; +- aucune modification du fichier source ; +- détection du type MIME réel ; +- détection des collisions ; +- traitement asynchrone ; +- annulation sûre. + +À la fin de l’import, la nouvelle preuve doit être automatiquement +sélectionnée dans la fenêtre de création de la personne. + +## Aperçu des preuves + +La fenêtre de sélection et d’import doit fournir un aperçu suffisamment +grand pour identifier le contenu sans occuper toute la fenêtre. + +Prévoir une zone responsive avec : + +- conservation du ratio ; +- ajustement à la zone ; +- zoom avant et arrière ; +- retour à l’ajustement ; +- défilement lorsque l’image est agrandie ; +- message explicite lorsque l’aperçu est indisponible. + +Prise en charge minimale : + +- image : aperçu direct ; +- PDF : première page avec navigation entre les pages ; +- vidéo : miniature, durée et informations principales ; +- EML : résumé des en-têtes et liste des pièces jointes ; +- autre fichier : icône, type MIME, taille et métadonnées disponibles. + +L’aperçu est une représentation dérivée. + +Il ne doit jamais modifier le fichier original. + +Toute conversion, génération de miniature, rotation, amélioration, +redimensionnement ou rendu PDF doit être réalisée dans une zone de +travail temporaire contrôlée. + +## Qualification du type de preuve + +Permettre de sélectionner ou confirmer un type contrôlé, par exemple : + +- Document d’identité ; +- Carte nationale d’identité ; +- Passeport ; +- Permis de conduire ; +- Document bancaire ; +- Capture d’écran ; +- Conversation ; +- Courrier électronique ; +- PDF ; +- Photo ; +- Vidéo ; +- Billet ou justificatif de commande ; +- Autre. + +La détection automatique peut proposer un type, mais l’utilisateur doit +toujours pouvoir le corriger avant validation. + +Le type choisi détermine les analyses proposées. + +## Document d’identité + +Lorsqu’une preuve est qualifiée comme document d’identité, proposer : + + Analyser le document + +Le traitement doit être asynchrone et annulable. + +Il doit : + +1. vérifier l’intégrité de la preuve ; +2. créer une copie de travail ; +3. conserver l’original strictement intact ; +4. appliquer les conversions uniquement à la copie ; +5. exécuter l’OCR ; +6. conserver le texte OCR brut ; +7. proposer des champs structurés ; +8. attendre une validation humaine avant toute intégration. + +## Champs OCR proposés + +Selon les informations réellement visibles, proposer notamment : + +- nom ; +- prénoms ; +- sexe déclaré ; +- date de naissance ; +- lieu de naissance ; +- nationalité déclarée ; +- taille ; +- numéro du document ; +- date de délivrance ; +- date d’expiration ; +- autorité de délivrance ; +- zone lisible par machine ; +- autres valeurs observées. + +Ne jamais inventer une valeur absente ou illisible. + +Chaque proposition doit posséder un état : + +- détectée ; +- partielle ; +- incertaine ; +- invalide ; +- corrigée ; +- rejetée ; +- confirmée. + +## Validation et correction humaine + +Avant la création de la personne, afficher côte à côte autant que possible : + +- l’aperçu du document ; +- les valeurs extraites ; +- les champs modifiables ; +- le niveau de confiance OCR ; +- la provenance précise. + +Pour chaque champ, conserver séparément : + +- valeur OCR brute ; +- valeur normalisée ; +- valeur corrigée manuellement ; +- valeur finalement confirmée ; +- méthode d’extraction ; +- langue OCR ; +- version de l’outil ; +- date UTC ; +- preuve source ; +- page ou zone source lorsque disponible. + +Une correction manuelle ne doit jamais écraser le résultat OCR brut. + +L’utilisateur doit pouvoir : + +- modifier une proposition ; +- rejeter une proposition ; +- laisser un champ vide ; +- revenir à la valeur OCR ; +- confirmer uniquement certains champs. + +## Authenticité et identité usurpée + +Ajouter un statut contrôlé pour le document : + +- Authenticité indéterminée ; +- Présumé authentique ; +- Suspect ; +- Présumé falsifié ; +- Falsifié confirmé ; +- Document usurpé présumé ; +- Document usurpé confirmé. + +Les statuts affirmatifs doivent nécessiter une validation explicite et +une justification. + +L’OCR ne doit jamais conclure : + +- que le document est authentique ; +- que la personne figurant sur le document est l’auteur ; +- que l’identité est réellement usurpée ; +- que le titulaire a participé aux faits. + +Dans le cas courant, le document doit pouvoir être enregistré comme : + + Identité présentée — authenticité indéterminée + +avec une hypothèse séparée : + + Identité potentiellement usurpée + +## Création de la personne + +Après validation, créer la personne avec uniquement les champs confirmés. + +La personne doit être liée à la preuve par une relation factuelle, par +exemple : + +- Identité observée dans ; +- Document présenté au nom de ; +- Données extraites depuis ; +- Identité déclarée dans. + +Ne pas créer automatiquement une relation : + +- Est l’auteur ; +- Identité réelle de ; +- A usurpé l’identité de. + +Les résultats OCR rejetés ne doivent pas devenir des attributs de la +personne. + +## Intégrité et provenance + +Le fichier original doit rester immuable. + +Avant chaque analyse : + +- recalculer ou vérifier le SHA-256 ; +- bloquer l’analyse en cas de divergence ; +- ne jamais réécrire l’empreinte enregistrée pour masquer une différence. + +Tous les fichiers dérivés doivent être identifiables comme tels : + +- miniature ; +- rendu de page PDF ; +- image préparée pour OCR ; +- recadrage ; +- correction d’orientation ; +- texte OCR ; +- JSON de métadonnées. + +Chaque dérivé doit conserver : + +- preuve parente ; +- outil ; +- version ; +- arguments ; +- date UTC ; +- SHA-256 ; +- statut ; +- avertissements. + +## Interface + +Le parcours doit rester possible depuis une seule fenêtre ou un assistant +cohérent : + + Création de la personne + ↓ + choix du rôle + ↓ + sélection ou import de la preuve + ↓ + aperçu + ↓ + qualification du type + ↓ + analyse facultative + ↓ + révision des propositions + ↓ + création de la personne et des relations confirmées + +Utiliser des boutons compacts avec icônes et infobulles. + +Ne pas ajouter de gros boutons occupant inutilement l’interface. + +La fermeture ou l’annulation ne doit créer ni personne partielle, ni +attribut partiel, ni relation partielle. + +## Transactions + +La création finale doit être transactionnelle pour : + +- la personne ; +- ses attributs confirmés ; +- le rattachement à la preuve ; +- les observations OCR confirmées ; +- les relations ; +- les références aux fichiers dérivés conservés. + +En cas d’échec : + +- rollback complet ; +- original inchangé ; +- aucune personne incomplète ; +- aucune relation orpheline ; +- aucun fichier temporaire présenté comme preuve permanente. + +## Tests obligatoires + +Ajouter des fixtures exclusivement synthétiques. + +Couvrir au minimum : + +1. choix de chaque rôle contrôlé ; +2. sélection d’une preuve existante ; +3. import d’une nouvelle preuve depuis la fenêtre ; +4. sélection automatique après import ; +5. aperçu image ; +6. aperçu PDF ; +7. aperçu indisponible ; +8. navigation entre pages ; +9. qualification manuelle du type ; +10. document d’identité déclenchant l’OCR ; +11. autre type ne déclenchant pas automatiquement l’OCR ; +12. original inchangé ; +13. SHA-256 inchangé ; +14. travail sur copie ; +15. texte OCR brut conservé ; +16. valeur normalisée séparée ; +17. correction manuelle séparée ; +18. rejet d’un champ ; +19. champ illisible laissé vide ; +20. création avec seulement les valeurs confirmées ; +21. provenance par champ ; +22. annulation ; +23. rollback ; +24. fermeture de fenêtre pendant l’analyse ; +25. changement d’enquête pendant l’analyse ; +26. aucune écriture dans la mauvaise base ; +27. aucune donnée réelle dans les fixtures ; +28. aucune conclusion automatique sur l’authenticité ; +29. aucune fusion automatique avec l’auteur présumé ; +30. réouverture de l’enquête avec données persistantes. + +Séparer autant que possible : + +- services d’import ; +- génération d’aperçu ; +- OCR ; +- modèle de révision ; +- DAO ; +- tests GTK ciblés ; +- validation visuelle manuelle. + +## Critères d’acceptation + +Le ticket est terminé seulement si : + +- le rôle de la personne peut être sélectionné ; +- une preuve existante peut être choisie ; +- une nouvelle preuve peut être importée sans quitter le parcours ; +- un aperçu exploitable est disponible ; +- le type de preuve peut être confirmé ou corrigé ; +- un document d’identité peut être analysé par OCR ; +- les propositions OCR sont modifiables avant intégration ; +- le texte OCR brut n’est jamais écrasé ; +- l’original reste inchangé ; +- la provenance est persistée ; +- seules les valeurs confirmées alimentent la personne ; +- aucune identité n’est attribuée automatiquement à l’auteur présumé ; +- tous les tests passent. + +## Sécurité de développement + +Codex et tout agent de développement doivent travailler uniquement avec : + +- le dépôt source ; +- des fixtures synthétiques ; +- des bases SQLite temporaires synthétiques. + +Ils ne doivent jamais accéder à : + +- Enquete.sqlite réelle ; +- documents d’identité réels ; +- captures réelles ; +- e-mails réels ; +- données bancaires réelles ; +- vidéos réelles ; +- autres preuves de l’enquête.