diff --git a/.gitignore b/.gitignore index 31445c9..0b6cd58 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ labfy-investigation # Executables de tests /tests/test_* +/tests/fake_document_tool # Conserver les sources des tests !/tests/test_*.c diff --git a/Makefile b/Makefile index 0ffc899..249ade5 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,36 @@ endif .DEFAULT_GOAL := all +SOURCE_SIZE_LIMIT := 2000 +# Exceptions historiques : plafonds constatés avant la tranche V18. +SOURCE_SIZE_EXCEPTIONS := \ + src/core/application.c:9325 \ + src/widgets/investigation_graph_view.c:4901 \ + src/widgets/workspace.c:4071 \ + src/views/main_window.c:2239 \ + src/views/create_relation_dialog.c:2043 + +.PHONY: check-source-size +check-source-size: + @limit=$(SOURCE_SIZE_LIMIT); failed=0; \ + files="$$(git ls-files --cached --others --exclude-standard -- \ + 'src/*.c' 'src/**/*.c' | sort -u)"; \ + for file in $$files; do \ + lines=$$(wc -l < "$$file"); allowed=$$limit; historical=0; \ + for exception in $(SOURCE_SIZE_EXCEPTIONS); do \ + case "$$exception" in "$$file":*) \ + allowed=$${exception##*:}; historical=1;; esac; \ + done; \ + if [ $$historical -eq 1 ]; then \ + echo "EXCEPTION HISTORIQUE $$file : $$lines/$$allowed lignes (limite normale $$limit)"; \ + fi; \ + if [ $$lines -gt $$allowed ]; then \ + echo "ERREUR taille source $$file : $$lines lignes, maximum $$allowed"; \ + failed=1; \ + fi; \ + done; \ + exit $$failed + CFLAGS = -std=c17 \ -Wall \ -Wextra \ @@ -160,6 +190,7 @@ TEST_PDF_ANALYSIS := tests/test_pdf_analysis TEST_DOCUMENT_TOOL_RUNNER := tests/test_document_tool_runner TEST_IDENTITY_OCR := tests/test_identity_ocr TEST_IDENTITY_OCR_PREPROCESSOR := tests/test_identity_ocr_preprocessor +TEST_IDENTITY_TRACEABILITY := tests/test_identity_traceability TEST_OCR_PROVENANCE_OVERLAY_GTK := tests/test_ocr_provenance_overlay_gtk TEST_EVIDENCE_METADATA_DIALOG_GTK := tests/test_evidence_metadata_dialog_gtk TEST_EVIDENCE_IDENTITY_IMPORT_GTK := tests/test_evidence_identity_import_gtk @@ -200,6 +231,17 @@ $(TEST_IDENTITY_OCR_PREPROCESSOR): tests/test_identity_ocr_preprocessor.c \ src/core/tool_registry.c $(CC) $(CFLAGS) $^ -o $@ $(LDFLAGS) +$(TEST_IDENTITY_TRACEABILITY): tests/test_identity_traceability.c \ + src/models/identity_traceability.c src/dao/identity_traceability_dao.c \ + src/views/person_vocabulary_adapter.c \ + src/models/person_role_assignment.c \ + src/models/identity_ocr.c src/dao/identity_ocr_dao.c \ + src/core/relation_type_normalizer.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 + $(CC) $(CFLAGS) $^ -o $@ $(LDFLAGS) + $(TEST_OCR_PROVENANCE_OVERLAY_GTK): \ tests/test_ocr_provenance_overlay_gtk.c \ src/widgets/ocr_provenance_overlay.c src/core/ocr_region_geometry.c \ @@ -890,6 +932,8 @@ $(TEST_PERSON_DIALOG_LIFECYCLE): tests/test_person_dialog_lifecycle.c \ $(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/identity_ocr_option_adapter.c \ src/core/person_dialog_lifecycle.c \ src/core/person_confirmation_summary.c \ src/core/evidence_staging.c src/core/evidence_staging_task.c \ @@ -908,12 +952,18 @@ $(TEST_CREATE_PERSON_DIALOG_GTK): tests/test_create_person_dialog_gtk.c \ src/core/background_task.c src/core/task_manager.c \ src/models/evidence_selection_model.c src/models/evidence_record.c \ src/models/person_role_assignment.c \ - src/models/person_evidence_selection.c + src/models/person_evidence_selection.c \ + src/models/identity_traceability.c src/dao/identity_traceability_dao.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 $(CC) $(CFLAGS) $^ -o $@ $(LDFLAGS) $(TEST_CREATE_PERSON_DIALOG_OCR_GTK): \ tests/test_create_person_dialog_ocr_gtk.c \ src/views/create_person_dialog.c src/views/dialog_geometry.c \ + src/views/person_vocabulary_adapter.c \ + src/views/identity_ocr_option_adapter.c \ src/core/person_dialog_lifecycle.c \ src/core/person_confirmation_summary.c \ src/core/evidence_staging.c src/core/evidence_staging_task.c \ @@ -937,6 +987,7 @@ $(TEST_CREATE_PERSON_DIALOG_OCR_GTK): \ src/models/evidence_selection_model.c src/models/evidence_record.c \ src/models/person_role_assignment.c \ src/models/person_evidence_selection.c \ + src/models/identity_traceability.c src/dao/identity_traceability_dao.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 $(FAKE_DOCUMENT_TOOL) @@ -981,6 +1032,7 @@ $(TEST_PERSON_CREATION_COORDINATOR): \ src/core/person_creation_coordinator.c src/core/evidence_staging.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 \ src/models/person_evidence_selection.c src/models/evidence_record.c \ src/models/person_role_assignment.c src/models/entity_record.c \ src/models/evidence_observation.c \ @@ -1127,6 +1179,7 @@ test: \ $(TEST_DOCUMENT_TOOL_RUNNER) \ $(TEST_IDENTITY_OCR) \ $(TEST_IDENTITY_OCR_PREPROCESSOR) \ + $(TEST_IDENTITY_TRACEABILITY) \ $(TEST_OCR_PROVENANCE_OVERLAY_GTK) \ $(TEST_EVIDENCE_METADATA_DIALOG_GTK) \ $(TEST_EVIDENCE_IDENTITY_IMPORT_GTK) \ @@ -1221,6 +1274,7 @@ test: \ @$(TEST_DOCUMENT_TOOL_RUNNER) @$(TEST_IDENTITY_OCR) @$(TEST_IDENTITY_OCR_PREPROCESSOR) + @$(TEST_IDENTITY_TRACEABILITY) @$(TEST_OCR_PROVENANCE_OVERLAY_GTK) @$(TEST_EVIDENCE_METADATA_DIALOG_GTK) @$(TEST_EVIDENCE_IDENTITY_IMPORT_GTK) @@ -1319,6 +1373,7 @@ clean: $(TEST_DOCUMENT_TOOL_RUNNER) \ $(TEST_IDENTITY_OCR) \ $(TEST_IDENTITY_OCR_PREPROCESSOR) \ + $(TEST_IDENTITY_TRACEABILITY) \ $(TEST_OCR_PROVENANCE_OVERLAY_GTK) \ $(TEST_EVIDENCE_METADATA_DIALOG_GTK) \ $(TEST_EVIDENCE_IDENTITY_IMPORT_GTK) \ diff --git a/database/schema_current.sql b/database/schema_current.sql index 7ec46b2..a58c7fc 100644 --- a/database/schema_current.sql +++ b/database/schema_current.sql @@ -306,6 +306,11 @@ CREATE TABLE IF NOT EXISTS identity_field_observations ( source_width INTEGER, source_height INTEGER, source_image_width INTEGER, source_image_height INTEGER, display_order INTEGER NOT NULL, reviewed_at TEXT NOT NULL, review_note TEXT, + confirmed_value TEXT, + confirmation_state TEXT NOT NULL DEFAULT 'unconfirmed' + CHECK(confirmation_state IN ('unconfirmed','human_confirmed')), + value_quality TEXT NOT NULL DEFAULT 'complete' + CHECK(value_quality IN ('complete','partial','uncertain','invalid')), CHECK ( (origin = 'manual_entry' AND raw_value IS NULL AND corrected_value IS NOT NULL AND confidence IS NULL) @@ -318,3 +323,116 @@ CREATE TABLE IF NOT EXISTS identity_field_observations ( FOREIGN KEY(ocr_run_id) REFERENCES identity_ocr_runs(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_identity_fields_observation ON identity_field_observations(observation_id,display_order); + +CREATE TABLE IF NOT EXISTS identification_status_vocabulary( + code TEXT PRIMARY KEY,label TEXT NOT NULL,description TEXT NOT NULL, + display_order INTEGER NOT NULL UNIQUE, + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN(0,1)), + requires_justification INTEGER NOT NULL DEFAULT 0 CHECK(requires_justification IN(0,1)), + sensitive INTEGER NOT NULL DEFAULT 0 CHECK(sensitive IN(0,1))); +INSERT OR IGNORE INTO identification_status_vocabulary VALUES +('unknown','Inconnu','Aucune identification disponible.',10,1,0,0), +('unverified','Non vérifié','Identification déclarée, non vérifiée.',20,1,0,0), +('presumed','Présumé','Identification présumée à justifier.',30,1,1,1), +('partially_identified','Partiellement identifié','Identification incomplète.',40,1,0,0), +('confirmed','Confirmé','Identification confirmée à justifier.',50,1,1,1), +('disputed','Contesté','Identification contestée à justifier.',60,1,1,1); +CREATE TABLE IF NOT EXISTS person_role_vocabulary( + code TEXT PRIMARY KEY,label TEXT NOT NULL,description TEXT NOT NULL, + display_order INTEGER NOT NULL UNIQUE,active INTEGER NOT NULL CHECK(active IN(0,1)), + requires_justification INTEGER NOT NULL CHECK(requires_justification IN(0,1)), + sensitive INTEGER NOT NULL CHECK(sensitive IN(0,1))); +INSERT OR IGNORE INTO person_role_vocabulary VALUES +('alleged_author','Auteur présumé','Rôle allégué.',10,1,1,1), +('presented_identity','Identité présentée','Identité déclarée.',20,1,0,0), +('potentially_impersonated_identity','Identité potentiellement usurpée','Hypothèse sensible.',30,1,1,1), +('victim','Victime','Personne déclarée victime.',40,1,0,0), +('witness','Témoin','Personne déclarée témoin.',50,1,0,0), +('declared_bank_holder','Titulaire bancaire déclaré','Titulaire déclaré.',60,1,0,0), +('intermediary','Intermédiaire','Intermédiaire observé.',70,1,0,0), +('mentioned_person','Personne citée','Personne citée.',80,1,0,0), +('other','Autre','Rôle factuel non couvert.',90,1,1,0), +('uncategorized','Non catégorisée','Code historique.',100,1,0,0), +('alleged_scammer','Escroc présumé (historique)','Code historique.',110,0,1,1), +('suspect','Suspect (historique)','Code historique.',120,0,1,1), +('related_person','Personne liée (historique)','Code historique.',130,0,0,0), +('impersonated_identity','Identité usurpée (historique)','Code historique.',140,0,1,1); +CREATE TABLE IF NOT EXISTS document_authenticity_assessments( + id TEXT PRIMARY KEY,evidence_id TEXT NOT NULL,ocr_run_id TEXT,status TEXT NOT NULL + CHECK(status IN('indeterminate','presumed_authentic','suspicious', + 'presumed_forged','confirmed_forged')),justification TEXT, + assessed_at TEXT NOT NULL,previous_assessment_id TEXT,technical_note TEXT, + origin TEXT NOT NULL CHECK(origin='human'), + CHECK(status='indeterminate' OR + (justification IS NOT NULL AND length(trim(justification))>0)), + FOREIGN KEY(evidence_id) REFERENCES preuves(id) ON DELETE RESTRICT, + FOREIGN KEY(ocr_run_id) REFERENCES identity_ocr_runs(id) ON DELETE SET NULL, + FOREIGN KEY(previous_assessment_id) REFERENCES document_authenticity_assessments(id)); +CREATE TABLE IF NOT EXISTS person_evidence_factual_relations( + id TEXT PRIMARY KEY,person_id TEXT NOT NULL,evidence_id TEXT NOT NULL, + ocr_run_id TEXT,relation_type TEXT NOT NULL CHECK(relation_type IN( + 'identity_observed_in','document_presented_in_name_of','declared_holder_in', + 'data_extracted_from')),factual_note TEXT,observed_at TEXT NOT NULL, + origin TEXT NOT NULL CHECK(origin='human'),active INTEGER NOT NULL DEFAULT 1 + CHECK(active IN(0,1)),FOREIGN KEY(person_id) REFERENCES entites(id), + FOREIGN KEY(evidence_id) REFERENCES preuves(id), + FOREIGN KEY(ocr_run_id) REFERENCES identity_ocr_runs(id) ON DELETE SET NULL); +CREATE TABLE IF NOT EXISTS person_identification_assessments( + id TEXT PRIMARY KEY,person_id TEXT NOT NULL,status_code TEXT NOT NULL, + justification TEXT,assessed_at TEXT NOT NULL,origin TEXT NOT NULL CHECK(origin='human'), + previous_assessment_id TEXT,CHECK(status_code<>'disputed' OR + (justification IS NOT NULL AND length(trim(justification))>0)), + FOREIGN KEY(person_id) REFERENCES entites(id), + FOREIGN KEY(status_code) REFERENCES identification_status_vocabulary(code), + FOREIGN KEY(previous_assessment_id) REFERENCES person_identification_assessments(id)); + +CREATE INDEX IF NOT EXISTS idx_authenticity_evidence_history + ON document_authenticity_assessments(evidence_id,assessed_at,id); +CREATE INDEX IF NOT EXISTS idx_person_evidence_factual_person + ON person_evidence_factual_relations(person_id,observed_at,id); +CREATE INDEX IF NOT EXISTS idx_person_evidence_factual_evidence + ON person_evidence_factual_relations(evidence_id,observed_at,id); +CREATE TRIGGER IF NOT EXISTS authenticity_v18_consistency +BEFORE INSERT ON document_authenticity_assessments BEGIN + SELECT CASE WHEN NEW.ocr_run_id IS NOT NULL AND NOT EXISTS( + SELECT 1 FROM identity_ocr_runs WHERE id=NEW.ocr_run_id + AND evidence_id=NEW.evidence_id) + THEN RAISE(ABORT,'OCR run belongs to another evidence') + WHEN NEW.previous_assessment_id IS NOT NULL AND NOT EXISTS( + SELECT 1 FROM document_authenticity_assessments + WHERE id=NEW.previous_assessment_id AND evidence_id=NEW.evidence_id) + THEN RAISE(ABORT,'previous assessment belongs to another evidence') END; +END; +CREATE TRIGGER IF NOT EXISTS factual_relation_v18_consistency +BEFORE INSERT ON person_evidence_factual_relations BEGIN + SELECT CASE WHEN NOT EXISTS(SELECT 1 FROM entites e JOIN types_entite t + ON t.id=e.type_id WHERE e.id=NEW.person_id AND t.code='person') + THEN RAISE(ABORT,'factual relation requires a person') + WHEN NEW.ocr_run_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM identity_ocr_runs + WHERE id=NEW.ocr_run_id AND evidence_id=NEW.evidence_id) + THEN RAISE(ABORT,'OCR run belongs to another evidence') END; +END; +CREATE TRIGGER IF NOT EXISTS identity_fields_v18_insert_guard +BEFORE INSERT ON identity_field_observations BEGIN + SELECT CASE + WHEN NEW.review_status IN('rejected','conflict') AND NEW.confirmed_value IS NOT NULL + THEN RAISE(ABORT,'non-projectable review status') + WHEN NEW.value_quality IN('uncertain','invalid') AND NEW.confirmed_value IS NOT NULL + THEN RAISE(ABORT,'non-projectable quality') + WHEN NEW.confirmed_value IS NOT NULL AND NEW.confirmation_state<>'human_confirmed' + THEN RAISE(ABORT,'human confirmation required') + WHEN NEW.confirmation_state='human_confirmed' AND NEW.confirmed_value IS NULL + THEN RAISE(ABORT,'confirmed value required') END; +END; +CREATE TRIGGER IF NOT EXISTS identity_fields_v18_update_guard +BEFORE UPDATE ON identity_field_observations BEGIN + SELECT CASE + WHEN NEW.review_status IN('rejected','conflict') AND NEW.confirmed_value IS NOT NULL + THEN RAISE(ABORT,'non-projectable review status') + WHEN NEW.value_quality IN('uncertain','invalid') AND NEW.confirmed_value IS NOT NULL + THEN RAISE(ABORT,'non-projectable quality') + WHEN NEW.confirmed_value IS NOT NULL AND NEW.confirmation_state<>'human_confirmed' + THEN RAISE(ABORT,'human confirmation required') + WHEN NEW.confirmation_state='human_confirmed' AND NEW.confirmed_value IS NULL + THEN RAISE(ABORT,'confirmed value required') END; +END; diff --git a/database/schema_v18.sql b/database/schema_v18.sql new file mode 100644 index 0000000..a7ea9e5 --- /dev/null +++ b/database/schema_v18.sql @@ -0,0 +1,217 @@ +/* Migration V18 — traçabilité humaine des documents d'identité. */ + +CREATE TABLE identification_status_vocabulary ( + code TEXT PRIMARY KEY, + label TEXT NOT NULL, + description TEXT NOT NULL, + display_order INTEGER NOT NULL UNIQUE, + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)), + requires_justification INTEGER NOT NULL DEFAULT 0 + CHECK(requires_justification IN (0,1)), + sensitive INTEGER NOT NULL DEFAULT 0 CHECK(sensitive IN (0,1)) +); +INSERT INTO identification_status_vocabulary VALUES +('unknown','Inconnu','Aucune identification disponible.',10,1,0,0), +('unverified','Non vérifié','Identification déclarée, non vérifiée.',20,1,0,0), +('presumed','Présumé','Identification présumée à justifier.',30,1,1,1), +('partially_identified','Partiellement identifié','Identification incomplète.',40,1,0,0), +('confirmed','Confirmé','Identification confirmée à justifier.',50,1,1,1), +('disputed','Contesté','Identification contestée à justifier.',60,1,1,1); + +CREATE TABLE person_role_vocabulary ( + code TEXT PRIMARY KEY, + label TEXT NOT NULL, + description TEXT NOT NULL, + display_order INTEGER NOT NULL UNIQUE, + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)), + requires_justification INTEGER NOT NULL DEFAULT 0 + CHECK(requires_justification IN (0,1)), + sensitive INTEGER NOT NULL DEFAULT 0 CHECK(sensitive IN (0,1)) +); +INSERT INTO person_role_vocabulary VALUES +('alleged_author','Auteur présumé','Rôle allégué, sans attribution automatique.',10,1,1,1), +('presented_identity','Identité présentée','Identité déclarée ou présentée dans une preuve.',20,1,0,0), +('potentially_impersonated_identity','Identité potentiellement usurpée','Hypothèse sensible nécessitant une justification.',30,1,1,1), +('victim','Victime','Personne déclarée victime.',40,1,0,0), +('witness','Témoin','Personne déclarée témoin.',50,1,0,0), +('declared_bank_holder','Titulaire bancaire déclaré','Titulaire déclaré par une source.',60,1,0,0), +('intermediary','Intermédiaire','Personne observée comme intermédiaire.',70,1,0,0), +('mentioned_person','Personne citée','Personne seulement citée.',80,1,0,0), +('other','Autre','Rôle factuel non couvert.',90,1,1,0), +('uncategorized','Non catégorisée','Code historique.',100,1,0,0), +('alleged_scammer','Escroc présumé (historique)','Code historique sensible.',110,0,1,1), +('suspect','Suspect (historique)','Code historique sensible.',120,0,1,1), +('related_person','Personne liée (historique)','Code historique.',130,0,0,0), +('impersonated_identity','Identité usurpée (historique)','Code historique sensible.',140,0,1,1); + +CREATE TABLE document_authenticity_assessments ( + id TEXT PRIMARY KEY, + evidence_id TEXT NOT NULL, + ocr_run_id TEXT, + status TEXT NOT NULL CHECK(status IN ( + 'indeterminate','presumed_authentic','suspicious', + 'presumed_forged','confirmed_forged')), + justification TEXT, + assessed_at TEXT NOT NULL CHECK(length(assessed_at)=20), + previous_assessment_id TEXT, + technical_note TEXT, + origin TEXT NOT NULL CHECK(origin='human'), + CHECK(status='indeterminate' OR + (justification IS NOT NULL AND length(trim(justification))>0)), + FOREIGN KEY(evidence_id) REFERENCES preuves(id) ON DELETE RESTRICT, + FOREIGN KEY(ocr_run_id) REFERENCES identity_ocr_runs(id) ON DELETE SET NULL, + FOREIGN KEY(previous_assessment_id) + REFERENCES document_authenticity_assessments(id) ON DELETE RESTRICT +); +CREATE INDEX idx_authenticity_evidence_history + ON document_authenticity_assessments(evidence_id,assessed_at,id); +CREATE UNIQUE INDEX idx_authenticity_previous + ON document_authenticity_assessments(previous_assessment_id) + WHERE previous_assessment_id IS NOT NULL; + +CREATE TABLE person_evidence_factual_relations ( + id TEXT PRIMARY KEY, + person_id TEXT NOT NULL, + evidence_id TEXT NOT NULL, + ocr_run_id TEXT, + relation_type TEXT NOT NULL CHECK(relation_type IN ( + 'identity_observed_in','document_presented_in_name_of', + 'declared_holder_in','data_extracted_from')), + factual_note TEXT, + observed_at TEXT NOT NULL CHECK(length(observed_at)=20), + origin TEXT NOT NULL CHECK(origin='human'), + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)), + 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 SET NULL +); +CREATE INDEX idx_person_evidence_factual_person + ON person_evidence_factual_relations(person_id,observed_at,id); +CREATE INDEX idx_person_evidence_factual_evidence + ON person_evidence_factual_relations(evidence_id,observed_at,id); +CREATE TRIGGER authenticity_v18_consistency +BEFORE INSERT ON document_authenticity_assessments BEGIN + SELECT CASE + WHEN NEW.ocr_run_id IS NOT NULL AND NOT EXISTS( + SELECT 1 FROM identity_ocr_runs + WHERE id=NEW.ocr_run_id AND evidence_id=NEW.evidence_id) + THEN RAISE(ABORT,'OCR run belongs to another evidence') + WHEN NEW.previous_assessment_id IS NOT NULL AND NOT EXISTS( + SELECT 1 FROM document_authenticity_assessments + WHERE id=NEW.previous_assessment_id AND evidence_id=NEW.evidence_id) + THEN RAISE(ABORT,'previous assessment belongs to another evidence') END; +END; +CREATE TRIGGER factual_relation_v18_consistency +BEFORE INSERT ON person_evidence_factual_relations BEGIN + SELECT CASE + WHEN NOT EXISTS(SELECT 1 FROM entites e JOIN types_entite t ON t.id=e.type_id + WHERE e.id=NEW.person_id AND t.code='person') + THEN RAISE(ABORT,'factual relation requires a person') + WHEN NEW.ocr_run_id IS NOT NULL AND NOT EXISTS( + SELECT 1 FROM identity_ocr_runs + WHERE id=NEW.ocr_run_id AND evidence_id=NEW.evidence_id) + THEN RAISE(ABORT,'OCR run belongs to another evidence') END; +END; + +CREATE TABLE person_identification_assessments ( + id TEXT PRIMARY KEY, + person_id TEXT NOT NULL, + status_code TEXT NOT NULL, + justification TEXT, + assessed_at TEXT NOT NULL CHECK(length(assessed_at)=20), + origin TEXT NOT NULL CHECK(origin='human'), + previous_assessment_id TEXT, + CHECK(status_code<>'disputed' OR + (justification IS NOT NULL AND length(trim(justification))>0)), + FOREIGN KEY(person_id) REFERENCES entites(id) ON DELETE CASCADE, + FOREIGN KEY(status_code) REFERENCES identification_status_vocabulary(code) + ON DELETE RESTRICT, + FOREIGN KEY(previous_assessment_id) + REFERENCES person_identification_assessments(id) ON DELETE RESTRICT +); + +CREATE TABLE identity_field_observations_v18 ( + id TEXT PRIMARY KEY, + observation_id TEXT NOT NULL, + field_code TEXT NOT NULL, + raw_value TEXT, + corrected_value TEXT, + normalized_value TEXT, + confidence REAL CHECK(confidence IS NULL OR confidence BETWEEN 0 AND 100), + review_status TEXT NOT NULL CHECK(review_status IN + ('proposed','accepted','modified','rejected','conflict')), + origin TEXT NOT NULL CHECK(origin IN + ('ocr','mrz','manual_override','manual_entry')), + evidence_id TEXT NOT NULL, + ocr_run_id TEXT NOT NULL, + page_number INTEGER NOT NULL CHECK(page_number>0), + source_x INTEGER,source_y INTEGER,source_width INTEGER,source_height INTEGER, + source_image_width INTEGER,source_image_height INTEGER, + display_order INTEGER NOT NULL CHECK(display_order>=0), + reviewed_at TEXT NOT NULL CHECK(length(reviewed_at)=20), + review_note TEXT, + confirmed_value TEXT, + confirmation_state TEXT NOT NULL DEFAULT 'unconfirmed' + CHECK(confirmation_state IN ('unconfirmed','human_confirmed')), + value_quality TEXT NOT NULL DEFAULT 'complete' + CHECK(value_quality IN ('complete','partial','uncertain','invalid')), + CHECK((origin='manual_entry' AND raw_value IS NULL + AND corrected_value IS NOT NULL AND confidence IS NULL) + OR (origin<>'manual_entry' AND raw_value IS NOT NULL)), + FOREIGN KEY(observation_id) REFERENCES identity_document_observations(id) + ON DELETE CASCADE, + FOREIGN KEY(evidence_id) REFERENCES preuves(id) ON DELETE CASCADE, + FOREIGN KEY(ocr_run_id) REFERENCES identity_ocr_runs(id) ON DELETE CASCADE +); +INSERT INTO identity_field_observations_v18( + id,observation_id,field_code,raw_value,corrected_value,normalized_value, + confidence,review_status,origin,evidence_id,ocr_run_id,page_number, + source_x,source_y,source_width,source_height,source_image_width, + source_image_height,display_order,reviewed_at,review_note) +SELECT id,observation_id,field_code,raw_value,corrected_value,normalized_value, + confidence,review_status,origin,evidence_id,ocr_run_id,page_number, + source_x,source_y,source_width,source_height,source_image_width, + source_image_height,display_order,reviewed_at,review_note +FROM identity_field_observations; +DROP TABLE identity_field_observations; +ALTER TABLE identity_field_observations_v18 + RENAME TO identity_field_observations; +CREATE INDEX idx_identity_fields_observation + ON identity_field_observations(observation_id,display_order); + +CREATE TRIGGER identity_fields_v18_insert_guard +BEFORE INSERT ON identity_field_observations +BEGIN + SELECT CASE + WHEN NEW.review_status IN ('rejected','conflict') + AND NEW.confirmed_value IS NOT NULL + THEN RAISE(ABORT,'rejected or conflict field cannot be confirmed') + WHEN NEW.value_quality IN ('uncertain','invalid') + AND NEW.confirmed_value IS NOT NULL + THEN RAISE(ABORT,'uncertain or invalid field cannot be confirmed') + WHEN NEW.confirmed_value IS NOT NULL + AND NEW.confirmation_state<>'human_confirmed' + THEN RAISE(ABORT,'confirmed value requires human confirmation') + WHEN NEW.confirmation_state='human_confirmed' + AND NEW.confirmed_value IS NULL + THEN RAISE(ABORT,'human confirmation requires a value') + END; +END; +CREATE TRIGGER identity_fields_v18_update_guard +BEFORE UPDATE ON identity_field_observations +BEGIN + SELECT CASE + WHEN NEW.review_status IN ('rejected','conflict') + AND NEW.confirmed_value IS NOT NULL + THEN RAISE(ABORT,'rejected or conflict field cannot be confirmed') + WHEN NEW.value_quality IN ('uncertain','invalid') + AND NEW.confirmed_value IS NOT NULL + THEN RAISE(ABORT,'uncertain or invalid field cannot be confirmed') + WHEN NEW.confirmed_value IS NOT NULL + AND NEW.confirmation_state<>'human_confirmed' + THEN RAISE(ABORT,'confirmed value requires human confirmation') + WHEN NEW.confirmation_state='human_confirmed' + AND NEW.confirmed_value IS NULL + THEN RAISE(ABORT,'human confirmation requires a value') + END; +END; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 73f30ac..edeb79d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -30,7 +30,7 @@ pause, retour au début, détachement et libération via des actions injectées. > **Version :** 3.2 > **Dernière mise à jour :** 2026-07-30 -> **Schéma SQLite courant :** V17 +> **Schéma SQLite courant :** V18 ## Personnes contextuelles — SQLite V14 @@ -99,6 +99,31 @@ première allocation réelle puis reste entièrement modifiable. Les actions restent fixes en bas. Cette politique exclut les alertes simples, les popups `GtkDropDown` et les sélecteurs de fichiers natifs. +## Traçabilité d’identité — SQLite V18 + +V18 sépare quatre dimensions qui ne doivent jamais être confondues : +l’identification d’une personne, son rôle contextuel, l’authenticité humaine +d’un document et la qualité d’une valeur OCR. Les statuts d’identification et +les rôles proviennent de vocabulaires SQLite ordonnés. Les codes historiques +de rôles sont conservés, mais les rôles sensibles ne sont jamais assignés par +un moteur automatique. + +`document_authenticity_assessments` conserve un historique append-only +d’évaluations humaines. Toute conclusion autre que `indeterminate` exige une +justification. `person_evidence_factual_relations` porte uniquement quatre +relations factuelles contrôlées et humaines ; elle ne remplace ni +`preuve_entites`, rattachement générique, ni `relations`, relation entre +entités. Aucun type « auteur », « identité réelle », « a falsifié » ou +« a usurpé » n’est accepté. + +Les observations OCR conservent désormais séparément `raw_value`, +`normalized_value`, `corrected_value` et `confirmed_value`. La confirmation +humaine et la qualification `complete`, `partial`, `uncertain` ou `invalid` +sont orthogonales au statut de révision. Les contraintes SQLite et le service +de validation interdisent la projection des valeurs rejetées, en conflit, +incertaines ou invalides. Cette tranche ne réalise encore aucune projection +automatique et ne fournit pas l’interface GTK complète de saisie. + --- ## 1. Objectif @@ -395,7 +420,7 @@ validation du chemin ↓ création de l'arborescence ↓ -initialisation transactionnelle de SQLite V17 +initialisation transactionnelle de SQLite V18 ↓ création de l'identité de l'enquête ↓ diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d2fedb6..6b67099 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -17,7 +17,7 @@ achèvement du ticket #109. > **Dernière mise à jour :** 2026-07-30 > **État du projet :** développement actif -> **Schéma SQLite courant :** V17 +> **Schéma SQLite courant :** V18 > **Usage opérationnel :** non prêt pour la production --- @@ -131,7 +131,7 @@ La branche `main` contient notamment : - création, validation et ouverture d'enquêtes ; - session d'enquête remplaçable proprement ; -- infrastructure SQLite et migrations jusqu'à V17 ; +- infrastructure SQLite et migrations jusqu'à V18 ; - couche Database, DAO et services métier ; - import de preuves avec copie contrôlée et SHA-256 ; - vérification d'intégrité et reclassement des preuves ; @@ -175,21 +175,19 @@ PARTIEL — VALIDATION MANUELLE DES TRANCHES LIVRÉES RÉUSSIE - consultation, révision sans Tesseract et nouvelle analyse depuis la fiche ; - provenance graphique et aperçu partagé avec PDF multipage ; - politique commune de géométrie des dialogues GTK métiers. +- fondations V18 : authenticité humaine historisée, relations factuelles + typées, vocabulaires d’identification et de rôles, cycle OCR jusqu’à la + valeur explicitement confirmée et contraintes anti-automatisme. Limitations restantes : 1. l’OCR groupé de plusieurs preuves dans une même opération n’est pas pris en charge ; -2. le statut contrôlé d’authenticité du document et la justification - obligatoire des statuts affirmatifs ne sont pas implémentés ; -3. la relation factuelle typée entre personne et preuve et certains états - d’identification demandés restent incomplets ; -4. une partie du vocabulaire des rôles demeure codée en dur ; -5. la provenance uniforme de tous les dérivés et la projection des seules +2. l’interface GTK complète de saisie de l’authenticité, des relations + factuelles, des états et des rôles V18 reste à 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 ; -6. les tests négatifs garantissant l’absence de relation automatique - attribuant l’identité ou le rôle d’auteur doivent encore être renforcés. 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 b811fca..810f69e 100644 --- a/docs/database/DATABASE_ARCHITECTURE.md +++ b/docs/database/DATABASE_ARCHITECTURE.md @@ -1,5 +1,21 @@ # Architecture de la base de données +## Extension V18 — traçabilité d’identité + +La migration transactionnelle `database/schema_v18.sql` ajoute les +vocabulaires `identification_status_vocabulary` et +`person_role_vocabulary`, l’historique +`document_authenticity_assessments`, les relations humaines contrôlées +`person_evidence_factual_relations` et l’historique +`person_identification_assessments`. + +`identity_field_observations` distingue les valeurs brute, normalisée, +corrigée et confirmée. `confirmation_state` prouve l’action humaine et +`value_quality` sépare la complétude de la décision de révision. Les +contraintes refusent toute confirmation d’un champ rejeté, en conflit, +incertain ou invalide. Aucun OCR ne crée une évaluation d’authenticité, une +relation factuelle, un rôle sensible ou un état `confirmed`. + > **Statut :** architecture courante > **Version du schéma :** V10 > **Dernière mise à jour :** 2026-07-24 diff --git a/include/core/person_creation_coordinator.h b/include/core/person_creation_coordinator.h index 6dd48a7..e5f8e52 100644 --- a/include/core/person_creation_coordinator.h +++ b/include/core/person_creation_coordinator.h @@ -4,6 +4,7 @@ #include "core/person_entity_service.h" #include "models/person_evidence_selection.h" #include "models/identity_ocr.h" +#include "models/identity_traceability.h" #include G_BEGIN_DECLS @@ -31,12 +32,19 @@ typedef enum { PERSON_CREATION_FAILURE_INSERT_DOCUMENT_OBSERVATION, PERSON_CREATION_FAILURE_INSERT_FIELD, PERSON_CREATION_FAILURE_CREATE_SOURCE, + PERSON_CREATION_FAILURE_INSERT_FACTUAL_RELATION, PERSON_CREATION_FAILURE_SESSION_BEFORE_COMMIT, PERSON_CREATION_FAILURE_ARTIFACT_TEXT_CHANGED, PERSON_CREATION_FAILURE_ARTIFACT_TSV_CHANGED, PERSON_CREATION_FAILURE_COMMIT, PERSON_CREATION_FAILURE_COMPENSATION } PersonCreationFailurePoint; +typedef struct { + const char *evidence_selection_identifier; + const char *ocr_run_identifier; + const char *relation_type; + const char *factual_note; +} PersonCreationFactualRelationInput; typedef gboolean (*PersonCreationSessionCheck)(gpointer user_data); typedef struct { PersonCreationFailurePoint failure_point; @@ -44,6 +52,8 @@ typedef struct { gboolean inject_compensation_failure; PersonCreationSessionCheck session_check; gpointer session_check_data; + /** PersonCreationFactualRelationInput* empruntés, choix humains explicites. */ + const GPtrArray *factual_relations; } PersonCreationCoordinatorOptions; typedef struct { const char *collected_at; diff --git a/include/dao/identity_ocr_dao.h b/include/dao/identity_ocr_dao.h index 998ff16..939b96f 100644 --- a/include/dao/identity_ocr_dao.h +++ b/include/dao/identity_ocr_dao.h @@ -21,6 +21,7 @@ typedef struct { typedef struct { char *id,*observation_id,*field_code,*raw_value,*corrected_value; char *normalized_value,*review_status,*origin,*evidence_id,*ocr_run_id; + char *confirmed_value,*confirmation_state,*value_quality; char *reviewed_at,*review_note; double confidence; gboolean has_confidence; gint64 page_number,source_x,source_y,source_width,source_height; gint64 source_image_width,source_image_height,display_order; @@ -47,6 +48,8 @@ IdentityFieldObservationRecord *identity_ocr_dao_find_field( IdentityOcrDao *dao,const char *identifier,GError **error); GPtrArray *identity_ocr_dao_list_fields_by_document(IdentityOcrDao *dao, const char *document_identifier,GError **error); +GPtrArray *identity_ocr_dao_list_confirmed_fields(IdentityOcrDao *dao, + const char *document_identifier,GError **error); IdentityOcrRun *identity_ocr_dao_load_run( IdentityOcrDao *dao,const char *investigation_root, const char *run_identifier,char **person_identifier,GError **error); diff --git a/include/dao/identity_traceability_dao.h b/include/dao/identity_traceability_dao.h new file mode 100644 index 0000000..5fcbef5 --- /dev/null +++ b/include/dao/identity_traceability_dao.h @@ -0,0 +1,29 @@ +#ifndef LABFY_IDENTITY_TRACEABILITY_DAO_H +#define LABFY_IDENTITY_TRACEABILITY_DAO_H +#include "database/database.h" +#include "models/identity_traceability.h" +#include +G_BEGIN_DECLS +typedef struct IdentityTraceabilityDao IdentityTraceabilityDao; +IdentityTraceabilityDao *identity_traceability_dao_new(Database *database); +void identity_traceability_dao_free(IdentityTraceabilityDao *dao); +gboolean identity_traceability_dao_insert_authenticity( + IdentityTraceabilityDao *dao,const DocumentAuthenticityAssessment *assessment, + GError **error); +DocumentAuthenticityAssessment *identity_traceability_dao_find_authenticity( + IdentityTraceabilityDao *dao,const char *identifier,GError **error); +GPtrArray *identity_traceability_dao_list_authenticity( + IdentityTraceabilityDao *dao,const char *evidence_identifier,GError **error); +DocumentAuthenticityAssessment *identity_traceability_dao_current_authenticity( + IdentityTraceabilityDao *dao,const char *evidence_identifier,GError **error); +gboolean identity_traceability_dao_insert_factual_relation( + IdentityTraceabilityDao *dao,const PersonEvidenceFactualRelation *relation, + GError **error); +GPtrArray *identity_traceability_dao_list_factual_relations( + IdentityTraceabilityDao *dao,const char *evidence_identifier,GError **error); +GPtrArray *identity_traceability_dao_list_roles( + IdentityTraceabilityDao *dao,gboolean include_inactive,GError **error); +GPtrArray *identity_traceability_dao_list_identification_statuses( + IdentityTraceabilityDao *dao,gboolean include_inactive,GError **error); +G_END_DECLS +#endif diff --git a/include/database/schema.h b/include/database/schema.h index 7335602..49f7ce3 100644 --- a/include/database/schema.h +++ b/include/database/schema.h @@ -98,6 +98,7 @@ bool schema_install_v14(Database *database); bool schema_install_v15(Database *database); bool schema_install_v16(Database *database); bool schema_install_v17(Database *database); +bool schema_install_v18(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 5c80c99..c6bfdda 100644 --- a/include/models/identity_ocr.h +++ b/include/models/identity_ocr.h @@ -37,6 +37,22 @@ const char *identity_field_observation_get_raw_value( const IdentityFieldObservation *field); const char *identity_field_observation_get_corrected_value( const IdentityFieldObservation *field); +const char *identity_field_observation_get_normalized_value( + const IdentityFieldObservation *field); +const char *identity_field_observation_get_confirmed_value( + const IdentityFieldObservation *field); +const char *identity_field_observation_get_value_quality( + const IdentityFieldObservation *field); +gboolean identity_field_observation_set_normalized_value( + IdentityFieldObservation *field, const char *value); +gboolean identity_field_observation_set_value_quality( + IdentityFieldObservation *field, const char *quality); +gboolean identity_field_observation_confirm( + IdentityFieldObservation *field, const char *value); +void identity_field_observation_clear_confirmation( + IdentityFieldObservation *field); +gboolean identity_field_observation_is_human_confirmed( + const IdentityFieldObservation *field); IdentityReviewStatus identity_field_observation_get_status( const IdentityFieldObservation *field); double identity_field_observation_get_confidence( diff --git a/include/models/identity_traceability.h b/include/models/identity_traceability.h new file mode 100644 index 0000000..95853f7 --- /dev/null +++ b/include/models/identity_traceability.h @@ -0,0 +1,58 @@ +#ifndef LABFY_IDENTITY_TRACEABILITY_H +#define LABFY_IDENTITY_TRACEABILITY_H +#include +G_BEGIN_DECLS + +typedef struct { + char *identifier, *evidence_identifier, *ocr_run_identifier; + char *status, *justification, *assessed_at; + char *previous_identifier, *technical_note, *origin; +} DocumentAuthenticityAssessment; + +typedef struct { + char *identifier, *person_identifier, *evidence_identifier; + char *ocr_run_identifier, *relation_type, *factual_note; + char *observed_at, *origin; + gboolean active; +} PersonEvidenceFactualRelation; + +typedef struct { + char *code, *label, *description; + gint display_order; + gboolean active, requires_justification, sensitive; +} PersonRoleVocabularyEntry; + +typedef PersonRoleVocabularyEntry IdentificationStatusVocabularyEntry; + +gboolean identity_traceability_authenticity_status_valid(const char *status); +gboolean identity_traceability_relation_type_valid(const char *type); +gboolean identity_traceability_identification_status_valid(const char *status); +gboolean identity_traceability_value_quality_valid(const char *quality); +gboolean identity_traceability_field_is_projectable( + const char *review_status, const char *quality, + const char *confirmation_state, const char *confirmed_value); + +DocumentAuthenticityAssessment *document_authenticity_assessment_new( + const char *identifier, const char *evidence_identifier, + const char *ocr_run_identifier, const char *status, + const char *justification, const char *assessed_at, + const char *previous_identifier, const char *technical_note); +DocumentAuthenticityAssessment *document_authenticity_assessment_copy( + const DocumentAuthenticityAssessment *assessment); +void document_authenticity_assessment_free( + DocumentAuthenticityAssessment *assessment); + +PersonEvidenceFactualRelation *person_evidence_factual_relation_new( + const char *identifier, const char *person_identifier, + const char *evidence_identifier, const char *ocr_run_identifier, + const char *relation_type, const char *factual_note, + const char *observed_at, gboolean active); +PersonEvidenceFactualRelation *person_evidence_factual_relation_copy( + const PersonEvidenceFactualRelation *relation); +void person_evidence_factual_relation_free( + PersonEvidenceFactualRelation *relation); +void person_role_vocabulary_entry_free(PersonRoleVocabularyEntry *entry); +void identification_status_vocabulary_entry_free( + IdentificationStatusVocabularyEntry *entry); +G_END_DECLS +#endif diff --git a/include/views/create_person_dialog.h b/include/views/create_person_dialog.h index 5e63912..8c2d9a4 100644 --- a/include/views/create_person_dialog.h +++ b/include/views/create_person_dialog.h @@ -27,7 +27,8 @@ typedef gboolean (*CreatePersonDialogSessionCheck)(gpointer user_data); * @return TRUE si le dialogue a été présenté. */ gboolean create_person_dialog_present(GtkWindow *parent, - const GPtrArray *evidence_records, const char *investigation_root_path, + Database *database, const GPtrArray *evidence_records, + const char *investigation_root_path, TaskManager *task_manager, const ToolInfo *tesseract_tool, CreatePersonDialogSessionCheck session_check, CreatePersonDialogCallback callback, gpointer user_data, diff --git a/include/views/identity_ocr_option_adapter.h b/include/views/identity_ocr_option_adapter.h new file mode 100644 index 0000000..fad0723 --- /dev/null +++ b/include/views/identity_ocr_option_adapter.h @@ -0,0 +1,9 @@ +#ifndef LABFY_IDENTITY_OCR_OPTION_ADAPTER_H +#define LABFY_IDENTITY_OCR_OPTION_ADAPTER_H +#include +G_BEGIN_DECLS +const char *identity_ocr_option_adapter_language_label(const char *code); +guint identity_ocr_option_adapter_default_language_index( + const GPtrArray *codes); +G_END_DECLS +#endif diff --git a/include/views/person_vocabulary_adapter.h b/include/views/person_vocabulary_adapter.h new file mode 100644 index 0000000..e01aa24 --- /dev/null +++ b/include/views/person_vocabulary_adapter.h @@ -0,0 +1,33 @@ +#ifndef LABFY_PERSON_VOCABULARY_ADAPTER_H +#define LABFY_PERSON_VOCABULARY_ADAPTER_H +#include "database/database.h" +#include "models/identity_traceability.h" +#include +G_BEGIN_DECLS +typedef struct PersonVocabularyAdapter PersonVocabularyAdapter; +PersonVocabularyAdapter *person_vocabulary_adapter_new( + Database *database, GError **error); +void person_vocabulary_adapter_free(PersonVocabularyAdapter *adapter); +const GPtrArray *person_vocabulary_adapter_get_roles( + const PersonVocabularyAdapter *adapter); +const GPtrArray *person_vocabulary_adapter_get_statuses( + const PersonVocabularyAdapter *adapter); +GtkStringList *person_vocabulary_adapter_create_role_labels( + const PersonVocabularyAdapter *adapter); +GtkStringList *person_vocabulary_adapter_create_status_labels( + const PersonVocabularyAdapter *adapter); +const char *person_vocabulary_adapter_status_code( + const PersonVocabularyAdapter *adapter, guint index); +const char *person_vocabulary_adapter_status_label( + const PersonVocabularyAdapter *adapter, guint index); +GPtrArray *person_vocabulary_adapter_selected_role_labels( + const GPtrArray *buttons); +GPtrArray *person_vocabulary_adapter_create_role_buttons( + const PersonVocabularyAdapter *adapter, GtkBox *container); +GPtrArray *person_vocabulary_adapter_build_role_assignments( + const PersonVocabularyAdapter *adapter, const GPtrArray *buttons, + const char *evidence_identifier); +gboolean person_vocabulary_adapter_justification_valid( + const PersonRoleVocabularyEntry *entry, const char *justification); +G_END_DECLS +#endif diff --git a/src/core/application.c b/src/core/application.c index 77200a9..0d53a1c 100644 --- a/src/core/application.c +++ b/src/core/application.c @@ -6116,6 +6116,7 @@ static void application_on_add_person_requested(gpointer user_data) const ToolInfo *tesseract = tool_registry_find(registry, "ocr.tesseract"); create_person_dialog_present(main_window_get_window(application->main_window), + investigation_session_get_database(application->session), records, investigation_project_get_root_path(project), application->task_manager, tesseract, application_person_dialog_session_matches, diff --git a/src/core/person_creation_coordinator.c b/src/core/person_creation_coordinator.c index 20ca075..567f79d 100644 --- a/src/core/person_creation_coordinator.c +++ b/src/core/person_creation_coordinator.c @@ -5,6 +5,7 @@ #include "dao/evidence_entity_dao.h" #include "dao/person_role_assignment_dao.h" #include "dao/identity_ocr_dao.h" +#include "dao/identity_traceability_dao.h" #include "database/transaction.h" #include #include @@ -261,6 +262,53 @@ static gboolean persist_ocr_runs(IdentityOcrDao *dao, const char *root, return TRUE; } +static gboolean persist_factual_relations(IdentityTraceabilityDao *dao, + const char *person_identifier, + const PersonEvidenceSelection *selection, + const GPtrArray *evidence_identifiers, + const GPtrArray *inputs, const char *timestamp, + FailureControl *failure, GError **error) +{ + for (guint i = 0; inputs != NULL && i < inputs->len; i++) { + const PersonCreationFactualRelationInput *input = + g_ptr_array_index((GPtrArray *) inputs, i); + const char *evidence_identifier = NULL; + for (guint j = 0; input != NULL && + j < person_evidence_selection_get_count(selection); j++) { + const PersonEvidenceSelectionItem *item = + person_evidence_selection_get(selection, j); + if (g_strcmp0( + person_evidence_selection_item_get_identifier(item), + input->evidence_selection_identifier) == 0) { + evidence_identifier = g_ptr_array_index( + (GPtrArray *) evidence_identifiers, j); + break; + } + } + char *identifier = g_uuid_string_random(); + PersonEvidenceFactualRelation *relation = + input != NULL && evidence_identifier != NULL + ? person_evidence_factual_relation_new(identifier, + person_identifier, evidence_identifier, + input->ocr_run_identifier, input->relation_type, + input->factual_note, timestamp, TRUE) : NULL; + g_free(identifier); + if (relation == NULL || + fail_at(failure, + PERSON_CREATION_FAILURE_INSERT_FACTUAL_RELATION, error) || + !identity_traceability_dao_insert_factual_relation( + dao, relation, error)) { + person_evidence_factual_relation_free(relation); + if (error != NULL && *error == NULL) + g_set_error_literal(error, coordinator_error(), 10, + "La relation factuelle explicite est invalide."); + return FALSE; + } + person_evidence_factual_relation_free(relation); + } + return TRUE; +} + static PersonCreationCoordinatorResult *person_creation_coordinator_execute_internal( Database *database, const char *root, const PersonEntityInput *person, const char *existing_person_identifier, @@ -276,6 +324,7 @@ static PersonCreationCoordinatorResult *person_creation_coordinator_execute_inte EvidenceEntityDao *link_dao = NULL; PersonRoleAssignmentDao *role_dao = NULL; IdentityOcrDao *ocr_dao = NULL; + IdentityTraceabilityDao *traceability_dao = NULL; EntityRecord *person_record = NULL; GPtrArray *created_paths = g_ptr_array_new_with_free_func(g_free); GPtrArray *created_directories = g_ptr_array_new_with_free_func(g_free); @@ -319,12 +368,14 @@ static PersonCreationCoordinatorResult *person_creation_coordinator_execute_inte link_dao = evidence_entity_dao_new(database, error); role_dao = person_role_assignment_dao_new(database, error); ocr_dao = identity_ocr_dao_new(database); + traceability_dao = identity_traceability_dao_new(database); person_record = entity_dao == NULL ? NULL : create_person ? build_person(person, result->person_identifier, timestamp, error) : entity_dao_find_by_identifier( entity_dao, result->person_identifier, error); if (timestamp == NULL || entity_dao == NULL || evidence_dao == NULL || link_dao == NULL || role_dao == NULL || ocr_dao == NULL || + traceability_dao == NULL || person_record == NULL || g_strcmp0(entity_record_get_type_identifier(person_record), "person") != 0 || @@ -419,6 +470,12 @@ static PersonCreationCoordinatorResult *person_creation_coordinator_execute_inte created_paths, created_directories, &failure, cancellable, error)) goto cleanup; + if (!persist_factual_relations(traceability_dao, + result->person_identifier, selection, + result->evidence_identifiers, + options != NULL ? options->factual_relations : NULL, + timestamp, &failure, error)) + 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", @@ -513,6 +570,7 @@ cleanup: evidence_entity_dao_free(link_dao); person_role_assignment_dao_free(role_dao); identity_ocr_dao_free(ocr_dao); + identity_traceability_dao_free(traceability_dao); g_free(timestamp); if (!success) { person_creation_coordinator_result_free(result); diff --git a/src/core/person_entity_service.c b/src/core/person_entity_service.c index 890ecf9..4475c49 100644 --- a/src/core/person_entity_service.c +++ b/src/core/person_entity_service.c @@ -14,8 +14,12 @@ static gboolean person_entity_service_status_valid(const char *status) { return g_strcmp0(status, "unknown") == 0 || + g_strcmp0(status, "unverified") == 0 || + g_strcmp0(status, "presumed") == 0 || + g_strcmp0(status, "partially_identified") == 0 || g_strcmp0(status, "suspected") == 0 || - g_strcmp0(status, "confirmed") == 0; + g_strcmp0(status, "confirmed") == 0 || + g_strcmp0(status, "disputed") == 0; } gboolean person_entity_service_update_display_name(Database *database, diff --git a/src/dao/identity_ocr_dao.c b/src/dao/identity_ocr_dao.c index 06a6996..a5a41db 100644 --- a/src/dao/identity_ocr_dao.c +++ b/src/dao/identity_ocr_dao.c @@ -66,24 +66,32 @@ gboolean identity_ocr_dao_insert(IdentityOcrDao*d,const char*person, IdentityReviewStatus status=identity_field_observation_get_status(f); const IdentitySourceBox*b=identity_field_observation_get_box(f);char*id=g_uuid_string_random(); s=database_statement_prepare(d->database,"INSERT INTO identity_field_observations(" - "id,observation_id,field_code,raw_value,corrected_value,confidence,review_status," + "id,observation_id,field_code,raw_value,corrected_value,normalized_value," + "confidence,review_status," "origin,evidence_id,ocr_run_id,page_number,source_x,source_y,source_width," - "source_height,source_image_width,source_image_height,display_order,reviewed_at)" - " VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);"); + "source_height,source_image_width,source_image_height,display_order,reviewed_at," + "confirmed_value,confirmation_state,value_quality)" + " VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);"); ok=s&&bind_text(s,1,id)&&bind_text(s,2,obs)&&bind_text(s,3,identity_field_observation_get_code(f))&& bind_text(s,4,identity_field_observation_get_raw_value(f))&& bind_text(s,5,identity_field_observation_get_corrected_value(f))&& + bind_text(s,6,identity_field_observation_get_normalized_value(f))&& (identity_field_observation_get_confidence(f)>=0? - database_statement_bind_double(s,6,identity_field_observation_get_confidence(f)): - database_statement_bind_null(s,6))&&bind_text(s,7,review_status_text(status))&& - bind_text(s,8,identity_field_observation_get_origin(f))&&bind_text(s,9,evidence)&& - bind_text(s,10,identity_ocr_run_get_identifier(r))&&database_statement_bind_int64(s,11,identity_ocr_run_get_page(r)); - for(int column=12;ok&&column<=17;column++){gint value=0;if(b&&b->available){ + database_statement_bind_double(s,7,identity_field_observation_get_confidence(f)): + database_statement_bind_null(s,7))&&bind_text(s,8,review_status_text(status))&& + bind_text(s,9,identity_field_observation_get_origin(f))&&bind_text(s,10,evidence)&& + bind_text(s,11,identity_ocr_run_get_identifier(r))&&database_statement_bind_int64(s,12,identity_ocr_run_get_page(r)); + for(int column=13;ok&&column<=18;column++){gint value=0;if(b&&b->available){ const gint values[]={b->x,b->y,b->width,b->height,b->image_width,b->image_height}; - value=values[column-12];ok=database_statement_bind_int64(s,column,value); + value=values[column-13];ok=database_statement_bind_int64(s,column,value); }else ok=database_statement_bind_null(s,column);} - ok=ok&&database_statement_bind_int64(s,18,identity_field_observation_get_order(f))&& - bind_text(s,19,timestamp)&&database_statement_step(s)==DATABASE_STATEMENT_STEP_DONE; + ok=ok&&database_statement_bind_int64(s,19,identity_field_observation_get_order(f))&& + bind_text(s,20,timestamp)&& + bind_text(s,21,identity_field_observation_get_confirmed_value(f))&& + bind_text(s,22,identity_field_observation_is_human_confirmed(f) + ?"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;}} g_free(obs);return TRUE; fail:g_set_error_literal(error,g_quark_from_static_string("identity-ocr-dao"),1, @@ -111,6 +119,8 @@ void identity_document_observation_record_free(IdentityDocumentObservationRecord void identity_field_observation_record_free(IdentityFieldObservationRecord*r) {if(!r)return;FREE_FIELD(r,id);FREE_FIELD(r,observation_id);FREE_FIELD(r,field_code); FREE_FIELD(r,raw_value);FREE_FIELD(r,corrected_value);FREE_FIELD(r,normalized_value); + FREE_FIELD(r,confirmed_value);FREE_FIELD(r,confirmation_state); + FREE_FIELD(r,value_quality); FREE_FIELD(r,review_status);FREE_FIELD(r,origin);FREE_FIELD(r,evidence_id); FREE_FIELD(r,ocr_run_id);FREE_FIELD(r,reviewed_at);FREE_FIELD(r,review_note);g_free(r);} @@ -188,7 +198,10 @@ static IdentityFieldObservationRecord *read_field(DatabaseStatement*s) ok=database_statement_column_int64(s,12+(int)i,values[i]); ok=ok&&database_statement_column_int64(s,18,&r->display_order)&& database_statement_column_text(s,19,&r->reviewed_at)&& - database_statement_column_text(s,20,&r->review_note); + database_statement_column_text(s,20,&r->review_note)&& + database_statement_column_text(s,21,&r->confirmed_value)&& + database_statement_column_text(s,22,&r->confirmation_state)&& + database_statement_column_text(s,23,&r->value_quality); if(!ok){identity_field_observation_record_free(r);return NULL;}return r; } static const char run_columns[]="id,evidence_id,expected_sha256,page_number," @@ -203,7 +216,8 @@ static const char document_columns[]="id,person_id,evidence_id,ocr_run_id," static const char field_columns[]="id,observation_id,field_code,raw_value," "corrected_value,normalized_value,confidence,review_status,origin,evidence_id," "ocr_run_id,page_number,source_x,source_y,source_width,source_height," - "source_image_width,source_image_height,display_order,reviewed_at,review_note"; + "source_image_width,source_image_height,display_order,reviewed_at,review_note," + "confirmed_value,confirmation_state,value_quality"; typedef gpointer(*ReadRecord)(DatabaseStatement*); static gpointer read_run_record(DatabaseStatement*s){return read_run(s);} @@ -272,6 +286,29 @@ GPtrArray *identity_ocr_dao_list_fields_by_document(IdentityOcrDao*d,const char* {return list_records(d,"identity_field_observations",field_columns, "observation_id",i,read_field_record, (GDestroyNotify)identity_field_observation_record_free,e);} +GPtrArray *identity_ocr_dao_list_confirmed_fields(IdentityOcrDao*d, + const char*i,GError**e) +{ + if(!d||!i){g_set_error_literal(e,g_quark_from_static_string("identity-ocr-dao"),2, + "Lecture OCR invalide.");return NULL;} + char*sql=g_strdup_printf("SELECT %s FROM identity_field_observations " + "WHERE observation_id=? AND confirmation_state='human_confirmed' " + "AND confirmed_value IS NOT NULL AND review_status IN ('accepted','modified') " + "AND value_quality IN ('complete','partial') ORDER BY display_order,rowid;", + field_columns); + DatabaseStatement*s=database_statement_prepare(d->database,sql);g_free(sql); + GPtrArray*a=g_ptr_array_new_with_free_func( + (GDestroyNotify)identity_field_observation_record_free); + if(!s||!database_statement_bind_text(s,1,i))goto failed; + for(;;){DatabaseStatementStepResult step=database_statement_step(s); + if(step==DATABASE_STATEMENT_STEP_DONE)break; + if(step!=DATABASE_STATEMENT_STEP_ROW)goto failed; + IdentityFieldObservationRecord*r=read_field(s);if(!r)goto failed;g_ptr_array_add(a,r);} + database_statement_finalize(s);return a; +failed:database_statement_finalize(s);g_ptr_array_unref(a); + g_set_error_literal(e,g_quark_from_static_string("identity-ocr-dao"),4, + "Impossible de lire les champs OCR confirmés.");return NULL; +} IdentityOcrRun *identity_ocr_dao_load_run( IdentityOcrDao*d,const char*root,const char*identifier, @@ -329,6 +366,11 @@ IdentityOcrRun *identity_ocr_dao_load_run( else if(g_strcmp0(f->review_status,"conflict")==0) identity_field_observation_mark_conflict(field); identity_field_observation_set_origin(field,f->origin); + if(f->normalized_value!=NULL) + identity_field_observation_set_normalized_value(field,f->normalized_value); + identity_field_observation_set_value_quality(field,f->value_quality); + if(g_strcmp0(f->confirmation_state,"human_confirmed")==0) + identity_field_observation_confirm(field,f->confirmed_value); identity_ocr_run_add_field(run,field); } g_clear_pointer(&fields,g_ptr_array_unref); @@ -354,34 +396,40 @@ static gboolean update_review_fields(IdentityOcrDao*d,const char*observation, char*id=g_uuid_string_random(); DatabaseStatement*s=database_statement_prepare(d->database, "INSERT INTO identity_field_observations(id,observation_id,field_code," - "raw_value,corrected_value,confidence,review_status,origin,evidence_id," + "raw_value,corrected_value,normalized_value,confidence,review_status,origin,evidence_id," "ocr_run_id,page_number,source_x,source_y,source_width,source_height," - "source_image_width,source_image_height,display_order,reviewed_at)" - " VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);"); + "source_image_width,source_image_height,display_order,reviewed_at," + "confirmed_value,confirmation_state,value_quality)" + " VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);"); gboolean ok=s&&bind_text(s,1,id)&&bind_text(s,2,observation)&& bind_text(s,3,identity_field_observation_get_code(f))&& bind_text(s,4,identity_field_observation_get_raw_value(f))&& bind_text(s,5,identity_field_observation_get_corrected_value(f))&& + bind_text(s,6,identity_field_observation_get_normalized_value(f))&& (identity_field_observation_get_confidence(f)>=0 - ?database_statement_bind_double(s,6, + ?database_statement_bind_double(s,7, identity_field_observation_get_confidence(f)) - :database_statement_bind_null(s,6))&& - bind_text(s,7,review_status_text( + :database_statement_bind_null(s,7))&& + bind_text(s,8,review_status_text( identity_field_observation_get_status(f)))&& - bind_text(s,8,identity_field_observation_get_origin(f))&& - bind_text(s,9,identity_ocr_run_get_evidence_id(run))&& - bind_text(s,10,identity_ocr_run_get_identifier(run))&& - database_statement_bind_int64(s,11,identity_ocr_run_get_page(run)); + bind_text(s,9,identity_field_observation_get_origin(f))&& + bind_text(s,10,identity_ocr_run_get_evidence_id(run))&& + bind_text(s,11,identity_ocr_run_get_identifier(run))&& + database_statement_bind_int64(s,12,identity_ocr_run_get_page(run)); const gint values[]={b!=NULL?b->x:0,b!=NULL?b->y:0, b!=NULL?b->width:0,b!=NULL?b->height:0, b!=NULL?b->image_width:0,b!=NULL?b->image_height:0}; - for(gint column=12;ok&&column<=17;column++) + for(gint column=13;ok&&column<=18;column++) ok=b!=NULL&&b->available - ?database_statement_bind_int64(s,column,values[column-12]) + ?database_statement_bind_int64(s,column,values[column-13]) :database_statement_bind_null(s,column); - ok=ok&&database_statement_bind_int64(s,18, + ok=ok&&database_statement_bind_int64(s,19, identity_field_observation_get_order(f))&& - bind_text(s,19,timestamp)&& + bind_text(s,20,timestamp)&& + bind_text(s,21,identity_field_observation_get_confirmed_value(f))&& + bind_text(s,22,identity_field_observation_is_human_confirmed(f) + ?"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; } diff --git a/src/dao/identity_traceability_dao.c b/src/dao/identity_traceability_dao.c new file mode 100644 index 0000000..73b6ea1 --- /dev/null +++ b/src/dao/identity_traceability_dao.c @@ -0,0 +1,184 @@ +#include "dao/identity_traceability_dao.h" +#include "database/statement.h" +struct IdentityTraceabilityDao{Database*database;}; +static GQuark domain(void){return g_quark_from_static_string("identity-traceability-dao");} +static void fail(GError**e,const char*m){if(e&&*e==NULL)g_set_error_literal(e,domain(),1,m);} +static gboolean bind(DatabaseStatement*s,int i,const char*v) +{return v?database_statement_bind_text(s,i,v):database_statement_bind_null(s,i);} +IdentityTraceabilityDao *identity_traceability_dao_new(Database*d) +{if(!d)return NULL;IdentityTraceabilityDao*dao=g_new0(IdentityTraceabilityDao,1); + dao->database=d;return dao;} +void identity_traceability_dao_free(IdentityTraceabilityDao*d){g_free(d);} + +gboolean identity_traceability_dao_insert_authenticity( + IdentityTraceabilityDao*d,const DocumentAuthenticityAssessment*a,GError**e) +{ + DocumentAuthenticityAssessment*valid=a?document_authenticity_assessment_copy(a):NULL; + if(!d||!valid){fail(e,"Évaluation d’authenticité invalide.");return FALSE;} + DatabaseStatement*s=database_statement_prepare(d->database, + "INSERT INTO document_authenticity_assessments VALUES(?,?,?,?,?,?,?,?,?);"); + gboolean ok=s&&bind(s,1,a->identifier)&&bind(s,2,a->evidence_identifier)&& + bind(s,3,a->ocr_run_identifier)&&bind(s,4,a->status)&&bind(s,5,a->justification)&& + bind(s,6,a->assessed_at)&&bind(s,7,a->previous_identifier)&& + bind(s,8,a->technical_note)&&bind(s,9,"human")&& + database_statement_step(s)==DATABASE_STATEMENT_STEP_DONE; + database_statement_finalize(s);document_authenticity_assessment_free(valid); + if(!ok)fail(e,"Impossible de conserver l’évaluation d’authenticité."); + return ok; +} +static DocumentAuthenticityAssessment *read_auth(DatabaseStatement*s) +{ + char *v[9]={0};gboolean ok=TRUE; + for(int i=0;i<9;i++)ok=ok&&database_statement_column_text(s,i,&v[i]); + DocumentAuthenticityAssessment*a=ok?document_authenticity_assessment_new( + v[0],v[1],v[2],v[3],v[4],v[5],v[6],v[7]):NULL; + for(int i=0;i<9;i++)g_free(v[i]); + return a; +} +static GPtrArray *auth_query(IdentityTraceabilityDao*d,const char*sql, + const char*value,GError**e) +{ + if(!d||!value){fail(e,"Lecture d’authenticité invalide.");return NULL;} + DatabaseStatement*s=database_statement_prepare(d->database,sql); + GPtrArray*a=g_ptr_array_new_with_free_func( + (GDestroyNotify)document_authenticity_assessment_free); + if(!s||!bind(s,1,value))goto bad; + for(;;){DatabaseStatementStepResult step=database_statement_step(s); + if(step==DATABASE_STATEMENT_STEP_DONE)break; + if(step!=DATABASE_STATEMENT_STEP_ROW)goto bad; + DocumentAuthenticityAssessment*r=read_auth(s);if(!r)goto bad;g_ptr_array_add(a,r);} + database_statement_finalize(s);return a; +bad:database_statement_finalize(s);g_ptr_array_unref(a); + fail(e,"Impossible de lire l’historique d’authenticité.");return NULL; +} +DocumentAuthenticityAssessment *identity_traceability_dao_find_authenticity( + IdentityTraceabilityDao*d,const char*id,GError**e) +{ + GPtrArray*a=auth_query(d,"SELECT * FROM document_authenticity_assessments " + "WHERE id=? ORDER BY assessed_at,id;",id,e);if(!a)return NULL; + DocumentAuthenticityAssessment*r=a->len?document_authenticity_assessment_copy( + g_ptr_array_index(a,0)):NULL;g_ptr_array_unref(a);return r; +} +GPtrArray *identity_traceability_dao_list_authenticity( + IdentityTraceabilityDao*d,const char*id,GError**e) +{return auth_query(d,"SELECT * FROM document_authenticity_assessments " + "WHERE evidence_id=? ORDER BY assessed_at,id;",id,e);} +DocumentAuthenticityAssessment *identity_traceability_dao_current_authenticity( + IdentityTraceabilityDao*d,const char*id,GError**e) +{ + GPtrArray*a=auth_query(d,"SELECT * FROM document_authenticity_assessments " + "WHERE evidence_id=? ORDER BY assessed_at DESC,id DESC LIMIT 1;",id,e); + if(!a)return NULL; + DocumentAuthenticityAssessment*r=a->len? + document_authenticity_assessment_copy(g_ptr_array_index(a,0)):NULL; + g_ptr_array_unref(a);return r; +} +gboolean identity_traceability_dao_insert_factual_relation( + IdentityTraceabilityDao*d,const PersonEvidenceFactualRelation*r,GError**e) +{ + PersonEvidenceFactualRelation*valid=r?person_evidence_factual_relation_copy(r):NULL; + if(!d||!valid){fail(e,"Relation factuelle invalide.");return FALSE;} + DatabaseStatement*s=database_statement_prepare(d->database, + "INSERT INTO person_evidence_factual_relations VALUES(?,?,?,?,?,?,?,?,?);"); + gboolean ok=s&&bind(s,1,r->identifier)&&bind(s,2,r->person_identifier)&& + bind(s,3,r->evidence_identifier)&&bind(s,4,r->ocr_run_identifier)&& + bind(s,5,r->relation_type)&&bind(s,6,r->factual_note)&&bind(s,7,r->observed_at)&& + bind(s,8,"human")&&database_statement_bind_int64(s,9,r->active?1:0)&& + database_statement_step(s)==DATABASE_STATEMENT_STEP_DONE; + database_statement_finalize(s);person_evidence_factual_relation_free(valid); + if(!ok)fail(e,"Impossible de conserver la relation factuelle."); + return ok; +} +GPtrArray *identity_traceability_dao_list_factual_relations( + IdentityTraceabilityDao*d,const char*id,GError**e) +{ + if(!d||!id){fail(e,"Lecture des relations factuelles invalide.");return NULL;} + DatabaseStatement*s=database_statement_prepare(d->database, + "SELECT id,person_id,evidence_id,ocr_run_id,relation_type,factual_note," + "observed_at,origin,active FROM person_evidence_factual_relations " + "WHERE evidence_id=? ORDER BY observed_at,id;"); + GPtrArray*a=g_ptr_array_new_with_free_func( + (GDestroyNotify)person_evidence_factual_relation_free); + if(!s||!bind(s,1,id))goto bad; + for(;;){DatabaseStatementStepResult step=database_statement_step(s); + if(step==DATABASE_STATEMENT_STEP_DONE)break; + if(step!=DATABASE_STATEMENT_STEP_ROW)goto bad; + char*v[8]={0};gint64 active=0;gboolean ok=TRUE; + for(int i=0;i<8;i++)ok=ok&&database_statement_column_text(s,i,&v[i]); + ok=ok&&database_statement_column_int64(s,8,&active); + PersonEvidenceFactualRelation*r=ok?person_evidence_factual_relation_new( + v[0],v[1],v[2],v[3],v[4],v[5],v[6],active!=0):NULL; + for(int i=0;i<8;i++)g_free(v[i]); + if(!r)goto bad; + g_ptr_array_add(a,r);} + database_statement_finalize(s);return a; +bad:database_statement_finalize(s);g_ptr_array_unref(a); + fail(e,"Impossible de lire les relations factuelles.");return NULL; +} +GPtrArray *identity_traceability_dao_list_roles( + IdentityTraceabilityDao*d,gboolean inactive,GError**e) +{ + if(!d){fail(e,"Lecture du vocabulaire invalide.");return NULL;} + DatabaseStatement*s=database_statement_prepare(d->database, + inactive?"SELECT code,label,description,display_order,active," + "requires_justification,sensitive FROM person_role_vocabulary " + "ORDER BY display_order,code;":"SELECT code,label,description,display_order," + "active,requires_justification,sensitive FROM person_role_vocabulary " + "WHERE active=1 ORDER BY display_order,code;"); + GPtrArray*a=g_ptr_array_new_with_free_func( + (GDestroyNotify)person_role_vocabulary_entry_free); + if(!s)goto bad; + for(;;){DatabaseStatementStepResult step=database_statement_step(s); + if(step==DATABASE_STATEMENT_STEP_DONE)break; + if(step!=DATABASE_STATEMENT_STEP_ROW)goto bad; + PersonRoleVocabularyEntry*r=g_new0(PersonRoleVocabularyEntry,1); + gint64 order=0,active=0,required=0,sensitive=0; + gboolean ok=database_statement_column_text(s,0,&r->code)&& + database_statement_column_text(s,1,&r->label)&& + database_statement_column_text(s,2,&r->description)&& + database_statement_column_int64(s,3,&order)&& + database_statement_column_int64(s,4,&active)&& + database_statement_column_int64(s,5,&required)&& + database_statement_column_int64(s,6,&sensitive); + if(!ok){person_role_vocabulary_entry_free(r);goto bad;} + r->display_order=(gint)order;r->active=active!=0; + r->requires_justification=required!=0;r->sensitive=sensitive!=0; + g_ptr_array_add(a,r);}database_statement_finalize(s);return a; +bad:database_statement_finalize(s);g_ptr_array_unref(a); + fail(e,"Impossible de lire le vocabulaire des rôles.");return NULL; +} +GPtrArray *identity_traceability_dao_list_identification_statuses( + IdentityTraceabilityDao*d,gboolean inactive,GError**e) +{ + if(!d){fail(e,"Lecture du vocabulaire invalide.");return NULL;} + DatabaseStatement*s=database_statement_prepare(d->database, + inactive?"SELECT code,label,description,display_order,active," + "requires_justification,sensitive FROM identification_status_vocabulary " + "ORDER BY display_order,code;":"SELECT code,label,description,display_order," + "active,requires_justification,sensitive FROM identification_status_vocabulary " + "WHERE active=1 ORDER BY display_order,code;"); + GPtrArray*a=g_ptr_array_new_with_free_func( + (GDestroyNotify)identification_status_vocabulary_entry_free); + if(!s)goto bad; + for(;;){DatabaseStatementStepResult step=database_statement_step(s); + if(step==DATABASE_STATEMENT_STEP_DONE)break; + if(step!=DATABASE_STATEMENT_STEP_ROW)goto bad; + IdentificationStatusVocabularyEntry*r=g_new0( + IdentificationStatusVocabularyEntry,1); + gint64 order=0,active=0,required=0,sensitive=0; + gboolean ok=database_statement_column_text(s,0,&r->code)&& + database_statement_column_text(s,1,&r->label)&& + database_statement_column_text(s,2,&r->description)&& + database_statement_column_int64(s,3,&order)&& + database_statement_column_int64(s,4,&active)&& + database_statement_column_int64(s,5,&required)&& + database_statement_column_int64(s,6,&sensitive); + if(!ok){identification_status_vocabulary_entry_free(r);goto bad;} + r->display_order=(gint)order;r->active=active!=0; + r->requires_justification=required!=0;r->sensitive=sensitive!=0; + g_ptr_array_add(a,r);} + database_statement_finalize(s);return a; +bad:database_statement_finalize(s);g_ptr_array_unref(a); + fail(e,"Impossible de lire le vocabulaire des états d’identification."); + return NULL; +} diff --git a/src/dao/person_role_assignment_dao.c b/src/dao/person_role_assignment_dao.c index cdbda0a..fa6f786 100644 --- a/src/dao/person_role_assignment_dao.c +++ b/src/dao/person_role_assignment_dao.c @@ -30,6 +30,8 @@ gboolean person_role_assignment_dao_insert(PersonRoleAssignmentDao *dao, "SELECT ?,?,?,?,?,?,?,?,? WHERE EXISTS(SELECT 1 FROM entites e " "JOIN types_entite t ON t.id=e.type_id WHERE e.id=? AND t.code='person') " "AND (? IS NULL OR EXISTS(SELECT 1 FROM preuves WHERE id=?)) " + "AND EXISTS(SELECT 1 FROM person_role_vocabulary v " + "WHERE v.code=? AND v.active=1) " "RETURNING id;"; DatabaseStatement *statement = NULL; GDateTime *now = NULL; @@ -65,6 +67,7 @@ gboolean person_role_assignment_dao_insert(PersonRoleAssignmentDao *dao, !(input->evidence_identifier != NULL ? database_statement_bind_text(statement, 12, input->evidence_identifier) : database_statement_bind_null(statement, 12)) || + !database_statement_bind_text(statement, 13, input->role_code) || database_statement_step(statement) != DATABASE_STATEMENT_STEP_ROW) goto failed; success = TRUE; diff --git a/src/database/database.c b/src/database/database.c index c9a8f4a..4e11d81 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 17 +#define DATABASE_SCHEMA_VERSION_CURRENT 18 /** * @brief Version actuelle sous forme textuelle pour metadata. */ -#define DATABASE_SCHEMA_VERSION_CURRENT_TEXT "17" +#define DATABASE_SCHEMA_VERSION_CURRENT_TEXT "18" /** * @brief Nom de l'application enregistré dans les métadonnées. @@ -917,6 +917,21 @@ rollback: return false; } +static bool database_migrate_v17_to_v18(Database *database) +{ + bool transaction_started = false; + if (database == NULL || !database_transaction_begin(database)) return false; + transaction_started = true; + if (!schema_install_v18(database) || + !database_update_schema_version(database, "18") || + !database_transaction_commit(database)) goto rollback; + return true; +rollback: + if (transaction_started && !database_transaction_rollback(database)) + g_warning("Impossible d’annuler la migration SQLite V17 vers V18."); + return false; +} + /** * @brief Garantit atomiquement la présence des extensions du schéma courant. */ @@ -1174,6 +1189,10 @@ bool database_migrate_to_latest( if (!database_migrate_v16_to_v17(database)) return false; schema_version = 17; break; + case 17: + if (!database_migrate_v17_to_v18(database)) return false; + schema_version = 18; + break; default: database_set_error( diff --git a/src/database/schema.c b/src/database/schema.c index 3385928..8f78c7a 100644 --- a/src/database/schema.c +++ b/src/database/schema.c @@ -407,6 +407,32 @@ bool schema_install_v17(Database *database) "la migration SQLite V17"); } +bool schema_install_v18(Database *database) +{ + sqlite3_stmt *statement = NULL; + sqlite3 *handle = database_get_handle(database); + if (handle != NULL && + sqlite3_prepare_v2(handle, + "SELECT confirmed_value,confirmation_state,value_quality " + "FROM identity_field_observations LIMIT 0", + -1, &statement, NULL) == SQLITE_OK) { + sqlite3_finalize(statement); + statement = NULL; + if (sqlite3_prepare_v2(handle, + "SELECT 1 FROM document_authenticity_assessments," + "person_evidence_factual_relations," + "identification_status_vocabulary," + "person_role_vocabulary LIMIT 0", + -1, &statement, NULL) == SQLITE_OK) { + sqlite3_finalize(statement); + return true; + } + } + sqlite3_finalize(statement); + return schema_execute_file(database, "database/schema_v18.sql", + "la migration SQLite V18"); +} + bool schema_ensure_current( Database *database ) diff --git a/src/models/identity_ocr.c b/src/models/identity_ocr.c index 3f978ac..71c957c 100644 --- a/src/models/identity_ocr.c +++ b/src/models/identity_ocr.c @@ -1,6 +1,7 @@ #include "models/identity_ocr.h" struct IdentityFieldObservation { char *code, *raw_value, *corrected_value, *normalized_value, *note; + char *confirmed_value, *value_quality; char *origin; double confidence; IdentityReviewStatus status; @@ -48,7 +49,7 @@ IdentityFieldObservation *identity_field_observation_new( IdentityFieldObservation *f=g_new0(IdentityFieldObservation,1); 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->order=order; + f->origin=g_strdup("ocr"); f->value_quality=g_strdup("complete"); f->order=order; if (box != NULL) f->box=*box; return f; } @@ -64,6 +65,7 @@ IdentityFieldObservation *identity_field_observation_new_manual( field->confidence = -1.0; field->status = IDENTITY_REVIEW_PROPOSED; field->origin = g_strdup("manual_entry"); + field->value_quality = g_strdup("complete"); field->order = order; return field; } @@ -78,6 +80,8 @@ IdentityFieldObservation *identity_field_observation_copy( f->code,f->corrected_value,f->order); c->corrected_value=g_strdup(f->corrected_value); 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); c->note=g_strdup(f->note); c->status=f->status; g_free(c->origin); c->origin=g_strdup(f->origin); return c; } @@ -86,7 +90,7 @@ void identity_field_observation_free(IdentityFieldObservation *f) if(f==NULL)return; 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); + g_free(f->origin);g_free(f->confirmed_value);g_free(f->value_quality);g_free(f); } gboolean identity_field_observation_accept(IdentityFieldObservation *f) { if(f==NULL)return FALSE;f->status=IDENTITY_REVIEW_ACCEPTED;return TRUE; } @@ -112,7 +116,8 @@ gboolean identity_field_observation_restore_raw(IdentityFieldObservation *f) return TRUE; } void identity_field_observation_reject(IdentityFieldObservation *f) -{if(f!=NULL)f->status=IDENTITY_REVIEW_REJECTED;} +{if(f!=NULL){f->status=IDENTITY_REVIEW_REJECTED; + g_clear_pointer(&f->confirmed_value,g_free);}} gboolean identity_field_observation_set_origin(IdentityFieldObservation*f, const char*origin) {if(f==NULL||(!g_str_equal(origin,"ocr")&&!g_str_equal(origin,"mrz")&& @@ -120,17 +125,44 @@ gboolean identity_field_observation_set_origin(IdentityFieldObservation*f, return FALSE; g_free(f->origin);f->origin=g_strdup(origin);return TRUE;} void identity_field_observation_mark_conflict(IdentityFieldObservation*f) -{if(f!=NULL)f->status=IDENTITY_REVIEW_CONFLICT;} +{if(f!=NULL){f->status=IDENTITY_REVIEW_CONFLICT; + 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(corrected_value,const char*,corrected_value,NULL) +FG(normalized_value,const char*,normalized_value,NULL) +FG(confirmed_value,const char*,confirmed_value,NULL) +FG(value_quality,const char*,value_quality,NULL) FG(origin,const char*,origin,NULL) FG(status,IdentityReviewStatus,status,IDENTITY_REVIEW_PROPOSED) 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_set_normalized_value( + IdentityFieldObservation*f,const char*v) +{if(!f||!v||!v[0]||!g_utf8_validate(v,-1,NULL))return FALSE; + g_free(f->normalized_value);f->normalized_value=g_strdup(v);return TRUE;} +gboolean identity_field_observation_set_value_quality( + IdentityFieldObservation*f,const char*q) +{if(!f||(!g_str_equal(q,"complete")&&!g_str_equal(q,"partial")&& + !g_str_equal(q,"uncertain")&&!g_str_equal(q,"invalid")))return FALSE; + g_free(f->value_quality);f->value_quality=g_strdup(q); + if(g_str_equal(q,"uncertain")||g_str_equal(q,"invalid")) + g_clear_pointer(&f->confirmed_value,g_free); + return TRUE;} +gboolean identity_field_observation_confirm(IdentityFieldObservation*f, + const char*v) +{if(!f||!v||!v[0]||f->status==IDENTITY_REVIEW_REJECTED|| + f->status==IDENTITY_REVIEW_CONFLICT|| + g_strcmp0(f->value_quality,"uncertain")==0|| + g_strcmp0(f->value_quality,"invalid")==0)return FALSE; + g_free(f->confirmed_value);f->confirmed_value=g_strdup(v);return TRUE;} +void identity_field_observation_clear_confirmation(IdentityFieldObservation*f) +{if(f)g_clear_pointer(&f->confirmed_value,g_free);} +gboolean identity_field_observation_is_human_confirmed( + const IdentityFieldObservation*f){return f&&f->confirmed_value!=NULL;} IdentityOcrRun *identity_ocr_run_new(const char *evidence_id, const char *sha,const char *type,const char *side,guint page, const char *languages,const char *profile) diff --git a/src/models/identity_traceability.c b/src/models/identity_traceability.c new file mode 100644 index 0000000..c962c77 --- /dev/null +++ b/src/models/identity_traceability.c @@ -0,0 +1,109 @@ +#include "models/identity_traceability.h" +#include + +static gboolean in_values(const char *value, const char *const *values, + gsize count) +{ + if (value == NULL) return FALSE; + for (gsize i = 0; i < count; i++) + if (g_str_equal(value, values[i])) return TRUE; + return FALSE; +} +static gboolean text(const char *value) +{ return value != NULL && value[0] != '\0' && g_utf8_validate(value,-1,NULL); } +static gboolean optional_text(const char *value) +{ return value == NULL || (text(value) && strlen(value) <= 65536); } +static gboolean uuid_or_null(const char *value) +{ return value == NULL || g_uuid_string_is_valid(value); } +static gboolean timestamp(const char *value) +{ return value != NULL && strlen(value) == 20; } + +gboolean identity_traceability_authenticity_status_valid(const char *value) +{ + static const char *const values[]={"indeterminate","presumed_authentic", + "suspicious","presumed_forged","confirmed_forged"}; + return in_values(value,values,G_N_ELEMENTS(values)); +} +gboolean identity_traceability_relation_type_valid(const char *value) +{ + static const char *const values[]={"identity_observed_in", + "document_presented_in_name_of","declared_holder_in", + "data_extracted_from"}; + return in_values(value,values,G_N_ELEMENTS(values)); +} +gboolean identity_traceability_identification_status_valid(const char *value) +{ + static const char *const values[]={"unknown","unverified","presumed", + "partially_identified","confirmed","disputed"}; + return in_values(value,values,G_N_ELEMENTS(values)); +} +gboolean identity_traceability_value_quality_valid(const char *value) +{ + static const char *const values[]={"complete","partial","uncertain","invalid"}; + return in_values(value,values,G_N_ELEMENTS(values)); +} +gboolean identity_traceability_field_is_projectable(const char *review, + const char *quality,const char *confirmation,const char *confirmed) +{ + return confirmed != NULL && confirmed[0] != '\0' && + g_strcmp0(confirmation,"human_confirmed")==0 && + (g_strcmp0(review,"accepted")==0||g_strcmp0(review,"modified")==0) && + (g_strcmp0(quality,"complete")==0||g_strcmp0(quality,"partial")==0); +} + +DocumentAuthenticityAssessment *document_authenticity_assessment_new( + const char *id,const char *evidence,const char *run,const char *status, + const char *justification,const char *at,const char *previous,const char *note) +{ + if(!g_uuid_string_is_valid(id)||!g_uuid_string_is_valid(evidence)|| + !uuid_or_null(run)||!uuid_or_null(previous)|| + !identity_traceability_authenticity_status_valid(status)|| + !timestamp(at)||!optional_text(justification)||!optional_text(note)|| + (g_strcmp0(status,"indeterminate")!=0&&!text(justification)))return NULL; + DocumentAuthenticityAssessment*a=g_new0(DocumentAuthenticityAssessment,1); + a->identifier=g_strdup(id);a->evidence_identifier=g_strdup(evidence); + a->ocr_run_identifier=g_strdup(run);a->status=g_strdup(status); + a->justification=g_strdup(justification);a->assessed_at=g_strdup(at); + a->previous_identifier=g_strdup(previous);a->technical_note=g_strdup(note); + a->origin=g_strdup("human");return a; +} +DocumentAuthenticityAssessment *document_authenticity_assessment_copy( + const DocumentAuthenticityAssessment*a) +{return a?document_authenticity_assessment_new(a->identifier, + a->evidence_identifier,a->ocr_run_identifier,a->status,a->justification, + a->assessed_at,a->previous_identifier,a->technical_note):NULL;} +void document_authenticity_assessment_free(DocumentAuthenticityAssessment*a) +{if(!a)return;g_free(a->identifier);g_free(a->evidence_identifier); + g_free(a->ocr_run_identifier);g_free(a->status);g_free(a->justification); + g_free(a->assessed_at);g_free(a->previous_identifier); + g_free(a->technical_note);g_free(a->origin);g_free(a);} + +PersonEvidenceFactualRelation *person_evidence_factual_relation_new( + const char *id,const char *person,const char *evidence,const char *run, + const char *type,const char *note,const char *at,gboolean active) +{ + if(!g_uuid_string_is_valid(id)||!g_uuid_string_is_valid(person)|| + !g_uuid_string_is_valid(evidence)||!uuid_or_null(run)|| + !identity_traceability_relation_type_valid(type)||!optional_text(note)|| + !timestamp(at))return NULL; + PersonEvidenceFactualRelation*r=g_new0(PersonEvidenceFactualRelation,1); + r->identifier=g_strdup(id);r->person_identifier=g_strdup(person); + r->evidence_identifier=g_strdup(evidence);r->ocr_run_identifier=g_strdup(run); + r->relation_type=g_strdup(type);r->factual_note=g_strdup(note); + r->observed_at=g_strdup(at);r->origin=g_strdup("human");r->active=active;return r; +} +PersonEvidenceFactualRelation *person_evidence_factual_relation_copy( + const PersonEvidenceFactualRelation*r) +{return r?person_evidence_factual_relation_new(r->identifier, + r->person_identifier,r->evidence_identifier,r->ocr_run_identifier, + r->relation_type,r->factual_note,r->observed_at,r->active):NULL;} +void person_evidence_factual_relation_free(PersonEvidenceFactualRelation*r) +{if(!r)return;g_free(r->identifier);g_free(r->person_identifier); + g_free(r->evidence_identifier);g_free(r->ocr_run_identifier); + g_free(r->relation_type);g_free(r->factual_note);g_free(r->observed_at); + g_free(r->origin);g_free(r);} +void person_role_vocabulary_entry_free(PersonRoleVocabularyEntry*e) +{if(!e)return;g_free(e->code);g_free(e->label);g_free(e->description);g_free(e);} +void identification_status_vocabulary_entry_free( + IdentificationStatusVocabularyEntry*e) +{person_role_vocabulary_entry_free(e);} diff --git a/src/views/create_person_dialog.c b/src/views/create_person_dialog.c index 7741437..b907ebf 100644 --- a/src/views/create_person_dialog.c +++ b/src/views/create_person_dialog.c @@ -4,6 +4,8 @@ ******************************************************************************/ #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 "core/evidence_staging.h" #include "core/evidence_staging_task.h" #include "core/person_confirmation_summary.h" @@ -78,7 +80,8 @@ typedef struct GtkStringList *evidence_labels; GtkStringList *retained_labels; GtkStringList *type_filter_labels; - GtkCheckButton *roles[9]; + GPtrArray *role_buttons; + PersonVocabularyAdapter *vocabularies; guint step; GPtrArray *evidence_identifiers; GPtrArray *visible_records; @@ -125,7 +128,6 @@ typedef struct { GWeakRef window; guint64 generation; } CreatePersonStagingContext; -static const char *const status_codes[] = {"unknown", "suspected", "confirmed"}; static void create_person_dialog_clear_preview(CreatePersonDialogState *state); static void create_person_dialog_select_record(CreatePersonDialogState *state, const EvidenceRecord *record, const char *business_type); @@ -162,33 +164,6 @@ static void identity_languages_worker(GTask *task, gpointer source, else g_task_return_error(task, error); } -static const char *identity_language_label(const char *code) -{ - if (g_str_equal(code, "fra")) return "Français (fra)"; - if (g_str_equal(code, "eng")) return "Anglais (eng)"; - if (g_str_equal(code, "fra+eng")) return "Français + anglais (fra+eng)"; - return code; -} - -static guint identity_default_language_index(const GPtrArray *codes) -{ - guint english_index = GTK_INVALID_LIST_POSITION; - - for (guint index = 0; codes != NULL && index < codes->len; index++) { - const char *code = g_ptr_array_index((GPtrArray *) codes, index); - - if (g_strcmp0(code, "fra") == 0) - return index; - if (g_strcmp0(code, "eng") == 0) - english_index = index; - } - return english_index != GTK_INVALID_LIST_POSITION - ? english_index - : (codes != NULL && codes->len > 0 - ? 0 - : GTK_INVALID_LIST_POSITION); -} - static const char *create_person_dialog_effective_ocr_mime( const PersonEvidenceSelectionItem *item) { @@ -235,11 +210,13 @@ static void identity_languages_completed(GObject *source, for (guint i = 0; state->ocr_language_codes != NULL && i < state->ocr_language_codes->len; i++) { const char *code = g_ptr_array_index(state->ocr_language_codes, i); - gtk_string_list_append(labels, identity_language_label(code)); + gtk_string_list_append(labels, + identity_ocr_option_adapter_language_label(code)); } gtk_drop_down_set_model(state->ocr_languages, G_LIST_MODEL(labels)); gtk_drop_down_set_selected(state->ocr_languages, - identity_default_language_index(state->ocr_language_codes)); + identity_ocr_option_adapter_default_language_index( + state->ocr_language_codes)); gtk_widget_set_sensitive(GTK_WIDGET(state->ocr_start), state->ocr_language_codes != NULL && state->ocr_language_codes->len > 0); @@ -749,6 +726,8 @@ static void create_person_dialog_state_free(gpointer data) g_clear_pointer(&state->ocr_runs, g_ptr_array_unref); g_clear_pointer(&state->ocr_language_codes, g_ptr_array_unref); g_clear_pointer(&state->ocr_overlay, ocr_provenance_overlay_free); + g_clear_pointer(&state->role_buttons, g_ptr_array_unref); + g_clear_pointer(&state->vocabularies, person_vocabulary_adapter_free); person_dialog_lifecycle_cancel(state->lifecycle); g_clear_pointer(&state->evidence_identifiers, g_ptr_array_unref); g_clear_pointer(&state->visible_records, g_ptr_array_unref); @@ -1301,6 +1280,8 @@ static void create_person_dialog_on_create(GtkButton *button, gpointer data) CreatePersonDialogResult *result = NULL; char *notes = NULL; guint status = gtk_drop_down_get_selected(state->status); + const GPtrArray *statuses = + person_vocabulary_adapter_get_statuses(state->vocabularies); (void) button; if (state->session_check != NULL && !state->session_check(state->user_data)) { @@ -1309,7 +1290,7 @@ static void create_person_dialog_on_create(GtkButton *button, gpointer data) gtk_widget_set_visible(GTK_WIDGET(state->error), TRUE); return; } - if (status >= G_N_ELEMENTS(status_codes) || + if (statuses == NULL || status >= statuses->len || gtk_editable_get_text(GTK_EDITABLE(state->designation))[0] == '\0') { gtk_label_set_text(state->error, "La désignation de la personne est obligatoire."); @@ -1323,7 +1304,8 @@ static void create_person_dialog_on_create(GtkButton *button, gpointer data) gtk_editable_get_text(GTK_EDITABLE(state->name))); result->pseudonym = create_person_dialog_copy( gtk_editable_get_text(GTK_EDITABLE(state->pseudonym))); - result->status = g_strdup(status_codes[status]); + result->status = g_strdup(person_vocabulary_adapter_status_code( + state->vocabularies, status)); result->notes = create_person_dialog_copy(notes); if (person_evidence_selection_get_count( state->person_evidence_selection) > 0) { @@ -1340,23 +1322,10 @@ static void create_person_dialog_on_create(GtkButton *button, gpointer data) result->input.notes = result->notes; result->input.confidence = gtk_spin_button_get_value_as_int(state->confidence); result->input.evidence_identifier = result->evidence_identifier; - result->role_assignments = g_ptr_array_new_with_free_func( - (GDestroyNotify) person_role_assignment_input_free); - static const char *const role_codes[] = { - "alleged_author", "presented_identity", - "potentially_impersonated_identity", "victim", "witness", - "declared_bank_holder", "intermediary", "mentioned_person", "other"}; - for (guint i = 0; i < G_N_ELEMENTS(role_codes); i++) - if (gtk_check_button_get_active(state->roles[i])) { - PersonRoleAssignmentInput assignment = { - .role_code = (char *) role_codes[i], - .evidence_identifier = result->evidence_identifier, - .provenance_kind = "manual", - .has_confidence = FALSE - }; - g_ptr_array_add(result->role_assignments, - person_role_assignment_input_copy(&assignment)); - } + result->role_assignments = + person_vocabulary_adapter_build_role_assignments( + state->vocabularies, state->role_buttons, + result->evidence_identifier); result->input.role_assignments = result->role_assignments; result->evidence_selection = state->person_evidence_selection; state->person_evidence_selection = NULL; @@ -1395,23 +1364,20 @@ 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 == 4) { - static const char *const status_labels[] = { - "Inconnu", "Présumé", "Confirmé"}; - GPtrArray *role_labels = g_ptr_array_new(); + GPtrArray *role_labels = + person_vocabulary_adapter_selected_role_labels( + state->role_buttons); char *notes = create_person_dialog_get_notes(state); char *text; - for (guint i = 0; i < G_N_ELEMENTS(state->roles); i++) - if (gtk_check_button_get_active(state->roles[i])) - g_ptr_array_add(role_labels, (gpointer) - gtk_check_button_get_label(state->roles[i])); + guint selected_status = gtk_drop_down_get_selected(state->status); + const char *status_label = + person_vocabulary_adapter_status_label( + state->vocabularies, selected_status); text = person_confirmation_summary_build_multiple( gtk_editable_get_text(GTK_EDITABLE(state->designation)), gtk_editable_get_text(GTK_EDITABLE(state->name)), gtk_editable_get_text(GTK_EDITABLE(state->pseudonym)), - gtk_drop_down_get_selected(state->status) < - G_N_ELEMENTS(status_labels) - ? status_labels[gtk_drop_down_get_selected(state->status)] - : "Non renseigné", + status_label, gtk_spin_button_get_value_as_int(state->confidence), notes, role_labels, state->person_evidence_selection); GString *confirmation = g_string_new(text); @@ -1512,20 +1478,29 @@ static void create_person_dialog_add_row(GtkGrid *grid, int row, gtk_grid_attach(grid, widget, 1, row, 1, 1); } gboolean create_person_dialog_present(GtkWindow *parent, - const GPtrArray *records, const char *investigation_root_path, + Database *database, const GPtrArray *records, + const char *investigation_root_path, TaskManager *task_manager, const ToolInfo *tesseract_tool, CreatePersonDialogSessionCheck session_check, CreatePersonDialogCallback callback, gpointer data, GDestroyNotify data_destroy) { - static const char *const statuses[] = {"Inconnu", "Présumé", "Confirmé", NULL}; CreatePersonDialogState *state = NULL; GtkWidget *box = NULL, *grid = NULL, *actions = NULL, *cancel = NULL; GtkWidget *roles_box = NULL, *evidence_box = NULL, *ocr_box = NULL; GtkWidget *summary = NULL; - if (parent == NULL || investigation_root_path == NULL || + if (parent == NULL || database == NULL || + investigation_root_path == NULL || task_manager == NULL) return FALSE; state = g_new0(CreatePersonDialogState, 1); + GError *vocabulary_error = NULL; + state->vocabularies = person_vocabulary_adapter_new( + database, &vocabulary_error); + if (state->vocabularies == NULL) { + g_clear_error(&vocabulary_error); + g_free(state); + return FALSE; + } state->callback = callback; state->session_check = session_check; state->user_data = data; state->user_data_destroy = data_destroy; state->evidence_identifiers = g_ptr_array_new_with_free_func(g_free); @@ -1568,7 +1543,10 @@ gboolean create_person_dialog_present(GtkWindow *parent, gtk_entry_set_placeholder_text(state->designation, "Personne présumée liée aux comptes"); state->name = GTK_ENTRY(gtk_entry_new()); state->pseudonym = GTK_ENTRY(gtk_entry_new()); - state->status = GTK_DROP_DOWN(gtk_drop_down_new_from_strings(statuses)); + GtkStringList *status_labels = + person_vocabulary_adapter_create_status_labels(state->vocabularies); + state->status = GTK_DROP_DOWN(gtk_drop_down_new( + G_LIST_MODEL(status_labels), NULL)); gtk_drop_down_set_selected(state->status, 1); state->confidence = GTK_SPIN_BUTTON(gtk_spin_button_new_with_range(0, 100, 5)); gtk_spin_button_set_value(state->confidence, 30); @@ -1620,15 +1598,9 @@ gboolean create_person_dialog_present(GtkWindow *parent, GTK_STACK_TRANSITION_TYPE_SLIDE_LEFT_RIGHT); gtk_stack_add_titled(state->stack, grid, "person", "1 — Personne"); roles_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); - static const char *const role_codes[] = { - "alleged_author", "presented_identity", - "potentially_impersonated_identity", "victim", "witness", - "declared_bank_holder", "intermediary", "mentioned_person", "other"}; - for (guint i = 0; i < G_N_ELEMENTS(role_codes); i++) { - state->roles[i] = GTK_CHECK_BUTTON(gtk_check_button_new_with_label( - person_role_assignment_role_label(role_codes[i]))); - gtk_box_append(GTK_BOX(roles_box), GTK_WIDGET(state->roles[i])); - } + state->role_buttons = + person_vocabulary_adapter_create_role_buttons( + state->vocabularies, GTK_BOX(roles_box)); gtk_stack_add_titled(state->stack, roles_box, "roles", "2 — Rôles"); evidence_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); gtk_box_append(GTK_BOX(evidence_box), gtk_label_new( diff --git a/src/views/dialog_geometry.c b/src/views/dialog_geometry.c index 629c8b7..fe4ae6b 100644 --- a/src/views/dialog_geometry.c +++ b/src/views/dialog_geometry.c @@ -107,7 +107,8 @@ static void paned_geometry_apply( { int width = gtk_widget_get_width(GTK_WIDGET(paned)); int position; - if (width <= 0 || geometry == NULL) return; + if (geometry == NULL || + width < geometry->minimum_start + geometry->minimum_end) return; position = (int) (width * geometry->ratio); position = MAX(geometry->minimum_start, position); position = MIN(position, MAX(0, width - geometry->minimum_end)); @@ -120,7 +121,8 @@ static gboolean paned_geometry_idle(gpointer data) LabfyPanedGeometry *geometry = g_object_get_data( G_OBJECT(paned), "labfy-paned-initial-geometry"); if (geometry == NULL) return G_SOURCE_REMOVE; - if (gtk_widget_get_width(GTK_WIDGET(paned)) <= 0) + if (gtk_widget_get_width(GTK_WIDGET(paned)) < + geometry->minimum_start + geometry->minimum_end) return G_SOURCE_CONTINUE; paned_geometry_apply(paned, geometry); return G_SOURCE_REMOVE; diff --git a/src/views/identity_ocr_option_adapter.c b/src/views/identity_ocr_option_adapter.c new file mode 100644 index 0000000..5332fe1 --- /dev/null +++ b/src/views/identity_ocr_option_adapter.c @@ -0,0 +1,23 @@ +#include "views/identity_ocr_option_adapter.h" + +const char *identity_ocr_option_adapter_language_label(const char *code) +{ + if (g_str_equal(code, "fra")) return "Français (fra)"; + if (g_str_equal(code, "eng")) return "Anglais (eng)"; + if (g_str_equal(code, "fra+eng")) + return "Français + anglais (fra+eng)"; + return code; +} + +guint identity_ocr_option_adapter_default_language_index( + const GPtrArray *codes) +{ + guint english_index = G_MAXUINT; + for (guint index = 0; codes != NULL && index < codes->len; index++) { + const char *code = g_ptr_array_index((GPtrArray *) codes, index); + if (g_strcmp0(code, "fra") == 0) return index; + if (g_strcmp0(code, "eng") == 0) english_index = index; + } + return english_index != G_MAXUINT ? english_index : + (codes != NULL && codes->len > 0 ? 0 : G_MAXUINT); +} diff --git a/src/views/person_vocabulary_adapter.c b/src/views/person_vocabulary_adapter.c new file mode 100644 index 0000000..0e8e1ef --- /dev/null +++ b/src/views/person_vocabulary_adapter.c @@ -0,0 +1,154 @@ +#include "views/person_vocabulary_adapter.h" +#include "dao/identity_traceability_dao.h" +#include "models/person_role_assignment.h" + +struct PersonVocabularyAdapter { + GPtrArray *roles; + GPtrArray *statuses; +}; + +PersonVocabularyAdapter *person_vocabulary_adapter_new( + Database *database, GError **error) +{ + IdentityTraceabilityDao *dao = identity_traceability_dao_new(database); + PersonVocabularyAdapter *adapter = NULL; + if (dao == NULL) return NULL; + adapter = g_new0(PersonVocabularyAdapter, 1); + adapter->roles = identity_traceability_dao_list_roles( + dao, FALSE, error); + if (adapter->roles != NULL) + adapter->statuses = + identity_traceability_dao_list_identification_statuses( + dao, FALSE, error); + identity_traceability_dao_free(dao); + if (adapter->roles == NULL || adapter->statuses == NULL) { + person_vocabulary_adapter_free(adapter); + return NULL; + } + return adapter; +} + +void person_vocabulary_adapter_free(PersonVocabularyAdapter *adapter) +{ + if (adapter == NULL) return; + g_clear_pointer(&adapter->roles, g_ptr_array_unref); + g_clear_pointer(&adapter->statuses, g_ptr_array_unref); + g_free(adapter); +} + +const GPtrArray *person_vocabulary_adapter_get_roles( + const PersonVocabularyAdapter *adapter) +{ return adapter != NULL ? adapter->roles : NULL; } + +const GPtrArray *person_vocabulary_adapter_get_statuses( + const PersonVocabularyAdapter *adapter) +{ return adapter != NULL ? adapter->statuses : NULL; } + +static GtkStringList *create_labels(const GPtrArray *entries) +{ + GtkStringList *labels = gtk_string_list_new(NULL); + for (guint i = 0; entries != NULL && i < entries->len; i++) { + PersonRoleVocabularyEntry *entry = g_ptr_array_index( + (GPtrArray *) entries, i); + gtk_string_list_append(labels, entry->label); + } + return labels; +} + +GtkStringList *person_vocabulary_adapter_create_role_labels( + const PersonVocabularyAdapter *adapter) +{ return create_labels(person_vocabulary_adapter_get_roles(adapter)); } + +GtkStringList *person_vocabulary_adapter_create_status_labels( + const PersonVocabularyAdapter *adapter) +{ return create_labels(person_vocabulary_adapter_get_statuses(adapter)); } + +const char *person_vocabulary_adapter_status_code( + const PersonVocabularyAdapter *adapter, guint index) +{ + const GPtrArray *statuses = + person_vocabulary_adapter_get_statuses(adapter); + return statuses != NULL && index < statuses->len + ? ((IdentificationStatusVocabularyEntry *) g_ptr_array_index( + (GPtrArray *) statuses, index))->code : NULL; +} + +const char *person_vocabulary_adapter_status_label( + const PersonVocabularyAdapter *adapter, guint index) +{ + const GPtrArray *statuses = + person_vocabulary_adapter_get_statuses(adapter); + return statuses != NULL && index < statuses->len + ? ((IdentificationStatusVocabularyEntry *) g_ptr_array_index( + (GPtrArray *) statuses, index))->label : "Non renseigné"; +} + +GPtrArray *person_vocabulary_adapter_selected_role_labels( + const GPtrArray *buttons) +{ + GPtrArray *labels = g_ptr_array_new(); + for (guint i = 0; buttons != NULL && i < buttons->len; i++) { + GtkCheckButton *button = g_ptr_array_index( + (GPtrArray *) buttons, i); + if (gtk_check_button_get_active(button)) + g_ptr_array_add(labels, (gpointer) + gtk_check_button_get_label(button)); + } + return labels; +} + +GPtrArray *person_vocabulary_adapter_create_role_buttons( + const PersonVocabularyAdapter *adapter, GtkBox *container) +{ + const GPtrArray *roles = person_vocabulary_adapter_get_roles(adapter); + GPtrArray *buttons = g_ptr_array_new(); + for (guint i = 0; roles != NULL && i < roles->len; i++) { + PersonRoleVocabularyEntry *entry = + g_ptr_array_index((GPtrArray *) roles, i); + GtkCheckButton *button = GTK_CHECK_BUTTON( + gtk_check_button_new_with_label(entry->label)); + gtk_widget_set_tooltip_text(GTK_WIDGET(button), entry->description); + g_ptr_array_add(buttons, button); + gtk_box_append(container, GTK_WIDGET(button)); + } + return buttons; +} + +GPtrArray *person_vocabulary_adapter_build_role_assignments( + const PersonVocabularyAdapter *adapter, const GPtrArray *buttons, + const char *evidence_identifier) +{ + const GPtrArray *roles = person_vocabulary_adapter_get_roles(adapter); + GPtrArray *assignments = g_ptr_array_new_with_free_func( + (GDestroyNotify) person_role_assignment_input_free); + for (guint i = 0; roles != NULL && buttons != NULL && + i < roles->len && i < buttons->len; i++) { + if (!gtk_check_button_get_active(g_ptr_array_index( + (GPtrArray *) buttons, i))) continue; + PersonRoleVocabularyEntry *entry = + g_ptr_array_index((GPtrArray *) roles, i); + PersonRoleAssignmentInput input = { + .role_code = entry->code, + .evidence_identifier = (char *) evidence_identifier, + .provenance_kind = "manual" + }; + g_ptr_array_add(assignments, + person_role_assignment_input_copy(&input)); + } + return assignments; +} + +gboolean person_vocabulary_adapter_justification_valid( + const PersonRoleVocabularyEntry *entry, const char *justification) +{ + char *copy; + gboolean valid; + if (entry == NULL) return FALSE; + if (!entry->requires_justification) return TRUE; + copy = g_strdup(justification); + if (copy != NULL) g_strstrip(copy); + valid = copy != NULL && copy[0] != '\0' && + g_utf8_validate(copy, -1, NULL); + g_free(copy); + return valid; +} diff --git a/tests/fake_document_tool b/tests/fake_document_tool deleted file mode 100755 index 4bccf56..0000000 Binary files a/tests/fake_document_tool and /dev/null differ diff --git a/tests/test_create_person_dialog_gtk.c b/tests/test_create_person_dialog_gtk.c index d328472..186b1ed 100644 --- a/tests/test_create_person_dialog_gtk.c +++ b/tests/test_create_person_dialog_gtk.c @@ -1,6 +1,8 @@ #include "views/create_person_dialog.h" #include +#include #include +#include typedef struct { @@ -241,6 +243,16 @@ static void activate(GtkApplication *application, gpointer data) { TestContext *context = data; GError *error = NULL; + char *database_path = NULL; + int database_fd = g_file_open_tmp( + "labfy-create-person-XXXXXX.sqlite", &database_path, &error); + g_assert_cmpint(database_fd, >=, 0); + close(database_fd); + g_unlink(database_path); + g_assert_true(database_initialize(database_path, + "Enquête SPECIMEN dialogue", "/tmp")); + Database *database = database_open(database_path); + g_assert_nonnull(database); GPtrArray *records = g_ptr_array_new_with_free_func( (GDestroyNotify) evidence_record_free); EvidenceRecord *record = evidence_record_new( @@ -270,8 +282,11 @@ static void activate(GtkApplication *application, gpointer data) gtk_application_window_new(application)); gtk_window_present(context->main_window); g_assert_true(create_person_dialog_present(context->main_window, - records, "/tmp", context->task_manager, NULL, NULL, completed, + database, records, "/tmp", context->task_manager, NULL, NULL, completed, context, NULL)); + database_close(database); + g_unlink(database_path); + g_free(database_path); g_ptr_array_unref(records); g_idle_add(click_cancel, context); } diff --git a/tests/test_create_person_dialog_ocr_gtk.c b/tests/test_create_person_dialog_ocr_gtk.c index 312425b..b6db498 100644 --- a/tests/test_create_person_dialog_ocr_gtk.c +++ b/tests/test_create_person_dialog_ocr_gtk.c @@ -270,8 +270,10 @@ static gboolean drive(gpointer data) g_assert_true(gtk_widget_get_mapped(actions)); if(context->layout_index==0){ int position=gtk_paned_get_position(GTK_PANED(paned)); - g_assert_cmpint(position,>=,600); - g_assert_cmpint(position,<=,680); + int allocated=gtk_widget_get_width(paned); + g_assert_cmpint(position,>=,480); + g_assert_cmpint(allocated-position,>=,240); + g_assert_cmpint(position,<,allocated-allocated/10); } context->layout_index++; context->layout_resize_pending=FALSE; @@ -582,10 +584,12 @@ static gboolean drive(gpointer data) verify_persisted_review(context); g_assert_true(gtk_widget_get_visible(GTK_WIDGET(context->main_window))); GPtrArray *empty=g_ptr_array_new(); - g_assert_true(create_person_dialog_present(context->main_window,empty, + Database *database = database_open(context->database_path); + g_assert_true(create_person_dialog_present(context->main_window,database,empty, context->root,context->task_manager, tool_registry_find(context->registry,"tesseract"),NULL,completed, context,NULL)); + database_close(database); g_ptr_array_unref(empty); GList *windows=gtk_application_get_windows(context->application); context->dialog=windows->data==context->main_window @@ -667,7 +671,7 @@ static void activate(GtkApplication *application,gpointer data) g_assert_true(evidence_dao_insert(evidence_dao, g_ptr_array_index(records,index),&error)); g_assert_no_error(error); - evidence_dao_free(evidence_dao);database_close(database); + evidence_dao_free(evidence_dao); context->registry=tool_registry_new(); char *tool=g_canonicalize_filename("tests/fake_document_tool",NULL); g_assert_true(tool_registry_register(context->registry,"tesseract", @@ -677,10 +681,11 @@ static void activate(GtkApplication *application,gpointer data) "5.0.0 SPECIMEN",&error));g_assert_no_error(error); context->main_window=GTK_WINDOW(gtk_application_window_new(application)); gtk_window_present(context->main_window); - g_assert_true(create_person_dialog_present(context->main_window,records, + g_assert_true(create_person_dialog_present(context->main_window,database,records, context->root,context->task_manager, tool_registry_find(context->registry,"tesseract"),NULL,completed, context,NULL)); + database_close(database); GList *windows=gtk_application_get_windows(application); context->dialog=windows->data==context->main_window ?GTK_WINDOW(windows->next->data):GTK_WINDOW(windows->data); diff --git a/tests/test_database.c b/tests/test_database.c index 1b92246..9f68738 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, "17") == 0); + assert(strcmp(schema_version, "18") == 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"); @@ -535,6 +535,14 @@ static void test_database_initialize_valid_database(void) test_database_assert_table_exists(database, "osint_execution_relations"); test_database_assert_table_exists(database, "comptes_sociaux"); test_database_assert_table_exists(database, "person_roles"); + test_database_assert_table_exists(database, + "document_authenticity_assessments"); + test_database_assert_table_exists(database, + "person_evidence_factual_relations"); + test_database_assert_table_exists(database, + "person_role_vocabulary"); + test_database_assert_table_exists(database, + "identification_status_vocabulary"); assert(strcmp(application_name, "Labfy Investigation") == 0); assert(created_at[0] != '\0'); @@ -993,7 +1001,7 @@ static void test_database_migrate_v1_to_v2(void) assert( strcmp( schema_version, - "17" + "18" ) == 0 ); @@ -1422,6 +1430,14 @@ 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_identification_assessments;" + "DROP TABLE person_evidence_factual_relations;" + "DROP TABLE document_authenticity_assessments;" + "DROP TABLE person_role_vocabulary;" + "DROP TABLE identification_status_vocabulary;" + "DROP TABLE identity_field_observations;" + "DROP TABLE identity_document_observations;" + "DROP TABLE identity_ocr_runs;" "DROP TABLE preuve_entite_sources;" "DROP TABLE person_role_assignments;" "UPDATE metadata SET value='12' WHERE key='schema_version';" @@ -1448,7 +1464,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, "17") == 0); + assert(strcmp(version, "18") == 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_dialog_geometry_gtk.c b/tests/test_dialog_geometry_gtk.c index 6fc3e35..680310a 100644 --- a/tests/test_dialog_geometry_gtk.c +++ b/tests/test_dialog_geometry_gtk.c @@ -54,14 +54,20 @@ static void test_paned_applied_once(void) gtk_window_present(window); for (guint frame = 0; frame < 6; frame++) wait_for_frame(GTK_WIDGET(window)); - int expected = (gtk_widget_get_width(GTK_WIDGET(paned)) * 2) / 3; - g_assert_cmpint(ABS(gtk_paned_get_position(paned) - expected), <=, 2); - gtk_paned_set_position(paned, 450); + int allocated = gtk_widget_get_width(GTK_WIDGET(paned)); + int expected = (allocated * 2) / 3; + int tolerance = MAX(4, allocated / 50); + int position = gtk_paned_get_position(paned); + g_assert_cmpint(ABS(position - expected), <=, tolerance); + g_assert_cmpint(position, >=, 320); + g_assert_cmpint(allocated - position, >=, 180); + int user_position = (allocated * 11) / 20; + gtk_paned_set_position(paned, user_position); gtk_window_set_default_size(window, 1000, 700); wait_for_frame(GTK_WIDGET(window)); - g_assert_cmpint(gtk_paned_get_position(paned), ==, 450); + g_assert_cmpint(gtk_paned_get_position(paned), ==, user_position); labfy_paned_apply_initial_ratio(paned, 0.5, 100, 100); - g_assert_cmpint(gtk_paned_get_position(paned), ==, 450); + g_assert_cmpint(gtk_paned_get_position(paned), ==, user_position); gtk_window_destroy(window); } diff --git a/tests/test_evidence_identity_import_gtk.c b/tests/test_evidence_identity_import_gtk.c index 64294bd..62950a5 100644 --- a/tests/test_evidence_identity_import_gtk.c +++ b/tests/test_evidence_identity_import_gtk.c @@ -36,6 +36,7 @@ typedef struct { gboolean import_done; gboolean cancellation_done; gboolean layout_resize_pending; + int user_paned_position; gboolean passed; gboolean revision_done; char *final_evidence_identifier; @@ -501,19 +502,35 @@ static gboolean drive(gpointer data) if (!context->layout_resize_pending) { int initial_position = gtk_paned_get_position(GTK_PANED(paned)); - int expected_position = - (gtk_widget_get_width(paned) * 2) / 3; + int allocated_width = gtk_widget_get_width(paned); + int start_min = 0, end_min = 0; + gtk_widget_measure(gtk_paned_get_start_child(GTK_PANED(paned)), + GTK_ORIENTATION_HORIZONTAL, -1, &start_min, NULL, NULL, NULL); + gtk_widget_measure(gtk_paned_get_end_child(GTK_PANED(paned)), + GTK_ORIENTATION_HORIZONTAL, -1, &end_min, NULL, NULL, NULL); + int minimum_start = MAX(480, start_min); + int minimum_end = MAX(320, end_min); + int expected_position = CLAMP((allocated_width * 2) / 3, + minimum_start, MAX(minimum_start, + allocated_width - minimum_end)); + int tolerance = MAX(4, allocated_width / 50); g_assert_cmpint( - ABS(initial_position - expected_position), <=, 3); - gtk_paned_set_position(GTK_PANED(paned), 650); + ABS(initial_position - expected_position), <=, tolerance); + g_assert_cmpint(initial_position, >=, minimum_start); + g_assert_cmpint(allocated_width - initial_position, >=, + minimum_end); + int user_position = (allocated_width * 11) / 20; + gtk_paned_set_position(GTK_PANED(paned), user_position); g_assert_cmpint( - gtk_paned_get_position(GTK_PANED(paned)), ==, 650); + gtk_paned_get_position(GTK_PANED(paned)), ==, user_position); + context->user_paned_position = user_position; gtk_window_set_default_size(dialog, 1000, 700); context->layout_resize_pending = TRUE; return G_SOURCE_CONTINUE; } g_assert_cmpint( - gtk_paned_get_position(GTK_PANED(paned)), ==, 650); + gtk_paned_get_position(GTK_PANED(paned)), ==, + context->user_paned_position); GtkDropDown *person = GTK_DROP_DOWN(find_named(root, "identity-person")); g_assert_nonnull(person); diff --git a/tests/test_identity_traceability.c b/tests/test_identity_traceability.c new file mode 100644 index 0000000..8ecb323 --- /dev/null +++ b/tests/test_identity_traceability.c @@ -0,0 +1,233 @@ +#include "dao/identity_traceability_dao.h" +#include "views/person_vocabulary_adapter.h" +#include "dao/identity_ocr_dao.h" +#include "database/database.h" +#include "models/identity_traceability.h" +#include +#include +#include + +#define EVIDENCE "10000000-0000-4000-8000-000000000018" +#define PERSON "20000000-0000-4000-8000-000000000018" +#define RUN "30000000-0000-4000-8000-000000000018" +#define DOCUMENT "40000000-0000-4000-8000-000000000018" +#define FIELD "50000000-0000-4000-8000-000000000018" +#define ASSESSMENT1 "60000000-0000-4000-8000-000000000018" +#define ASSESSMENT2 "60000000-0000-4000-8000-000000000019" +#define FACT "70000000-0000-4000-8000-000000000018" +#define AT "2026-07-30T10:00:00Z" + +typedef struct{char*dir,*path;Database*database;}Fixture; +static void exec_ok(sqlite3*d,const char*sql) +{char*message=NULL;g_assert_cmpint(sqlite3_exec(d,sql,NULL,NULL,&message),==,SQLITE_OK); + sqlite3_free(message);} +static char *scalar(sqlite3*d,const char*sql) +{sqlite3_stmt*s=NULL;g_assert_cmpint(sqlite3_prepare_v2(d,sql,-1,&s,NULL),==,SQLITE_OK); + g_assert_cmpint(sqlite3_step(s),==,SQLITE_ROW);char*r=g_strdup( + (const char*)sqlite3_column_text(s,0));sqlite3_finalize(s);return r;} +static Fixture fixture_new(void) +{ + Fixture f={0};GError*e=NULL;f.dir=g_dir_make_tmp("labfy-v18-XXXXXX",&e); + g_assert_no_error(e);f.path=g_build_filename(f.dir,"Enquete.sqlite",NULL); + g_assert_true(database_initialize(f.path,"SPECIMEN V18",f.dir)); + sqlite3*d=NULL;g_assert_cmpint(sqlite3_open(f.path,&d),==,SQLITE_OK); + exec_ok(d,"PRAGMA foreign_keys=ON;" + "INSERT INTO preuves(id,name,relative_path,type_id,size_bytes,sha256," + "imported_at,updated_at,status,locked,original_name) VALUES('" EVIDENCE + "','specimen.png','specimen.png',2,8," + "'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa','" AT + "','" AT "','active',0,'specimen.png');" + "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','tesseract','fra','fra','SPECIMEN','none','" AT + "','success','raw.txt','bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'," + "'raw.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('" + DOCUMENT "','" 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) VALUES('" FIELD "','" DOCUMENT "','surname'," + "'BRUT SPECIMEN','BRUT SPECIMEN','accepted','ocr','" EVIDENCE "','" RUN + "',1,0,'" AT "');"); + sqlite3_close(d);f.database=database_open(f.path);g_assert_nonnull(f.database); + g_assert_true(database_migrate_to_latest(f.database));return f; +} +static void fixture_free(Fixture*f) +{database_close(f->database);g_remove(f->path);g_rmdir(f->dir); + g_free(f->path);g_free(f->dir);} + +static void test_models(void) +{ + PersonRoleVocabularyEntry justified={.requires_justification=TRUE}; + g_assert_false(person_vocabulary_adapter_justification_valid( + &justified," ")); + g_assert_true(person_vocabulary_adapter_justification_valid( + &justified,"Justification SPECIMEN")); + g_assert_true(identity_traceability_identification_status_valid("disputed")); + g_assert_false(identity_traceability_identification_status_valid("identified")); + g_assert_false(identity_traceability_relation_type_valid("is_author")); + g_assert_false(identity_traceability_field_is_projectable( + "accepted","uncertain","human_confirmed","SPECIMEN")); + g_assert_true(identity_traceability_field_is_projectable( + "modified","partial","human_confirmed","SPECIMEN")); + g_assert_null(document_authenticity_assessment_new(ASSESSMENT1,EVIDENCE,NULL, + "confirmed_forged",NULL,AT,NULL,NULL)); + DocumentAuthenticityAssessment*a=document_authenticity_assessment_new( + ASSESSMENT1,EVIDENCE,NULL,"confirmed_forged","Justification SPECIMEN",AT,NULL,NULL); + g_assert_nonnull(a);DocumentAuthenticityAssessment*c= + document_authenticity_assessment_copy(a);g_assert_cmpstr(c->status,==,a->status); + document_authenticity_assessment_free(c);document_authenticity_assessment_free(a); +} +static void test_dao_history_and_relations(void) +{ + Fixture f=fixture_new();GError*e=NULL; + IdentityTraceabilityDao*d=identity_traceability_dao_new(f.database); + DocumentAuthenticityAssessment*a1=document_authenticity_assessment_new( + ASSESSMENT1,EVIDENCE,RUN,"indeterminate",NULL,AT,NULL,"Note SPECIMEN"); + DocumentAuthenticityAssessment*a2=document_authenticity_assessment_new( + ASSESSMENT2,EVIDENCE,RUN,"suspicious","Anomalie SPECIMEN", + "2026-07-30T11:00:00Z",ASSESSMENT1,NULL); + g_assert_true(identity_traceability_dao_insert_authenticity(d,a1,&e)); + g_assert_no_error(e);g_assert_true(identity_traceability_dao_insert_authenticity(d,a2,&e)); + GPtrArray*h=identity_traceability_dao_list_authenticity(d,EVIDENCE,&e); + g_assert_cmpuint(h->len,==,2);g_ptr_array_unref(h); + DocumentAuthenticityAssessment*current= + identity_traceability_dao_current_authenticity(d,EVIDENCE,&e); + g_assert_cmpstr(current->identifier,==,ASSESSMENT2); + PersonEvidenceFactualRelation*r=person_evidence_factual_relation_new( + FACT,PERSON,EVIDENCE,RUN,"identity_observed_in","Observation SPECIMEN",AT,TRUE); + g_assert_true(identity_traceability_dao_insert_factual_relation(d,r,&e)); + GPtrArray*relations=identity_traceability_dao_list_factual_relations(d,EVIDENCE,&e); + g_assert_cmpuint(relations->len,==,1);g_ptr_array_unref(relations); + GPtrArray*roles=identity_traceability_dao_list_roles(d,TRUE,&e); + g_assert_cmpuint(roles->len,>=,14);g_ptr_array_unref(roles); + roles=identity_traceability_dao_list_roles(d,FALSE,&e); + g_assert_cmpuint(roles->len,==,10); + for(guint i=0;ilen;i++){ + PersonRoleVocabularyEntry*role=g_ptr_array_index(roles,i); + g_assert_true(role->active);g_assert_nonnull(role->code); + g_assert_nonnull(role->label);g_assert_nonnull(role->description); + if(i>0)g_assert_cmpint(((PersonRoleVocabularyEntry*) + g_ptr_array_index(roles,i-1))->display_order,<,role->display_order); + } + g_ptr_array_unref(roles); + GPtrArray*statuses=identity_traceability_dao_list_identification_statuses( + d,FALSE,&e);g_assert_no_error(e);g_assert_cmpuint(statuses->len,==,6); + for(guint i=0;ilen;i++){ + IdentificationStatusVocabularyEntry*status=g_ptr_array_index(statuses,i); + g_assert_true(status->active);g_assert_nonnull(status->description); + if(i>0)g_assert_cmpint(((IdentificationStatusVocabularyEntry*) + g_ptr_array_index(statuses,i-1))->display_order,<,status->display_order); + } + g_ptr_array_unref(statuses); + person_evidence_factual_relation_free(r); + document_authenticity_assessment_free(current); + document_authenticity_assessment_free(a2);document_authenticity_assessment_free(a1); + identity_traceability_dao_free(d);fixture_free(&f); +} +static void test_sqlite_negative_guards(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=ON;"); + char*before=scalar(d,"SELECT raw_value FROM identity_field_observations WHERE id='" FIELD "';"); + char*message=NULL; + g_assert_cmpint(sqlite3_exec(d,"INSERT INTO document_authenticity_assessments" + "(id,evidence_id,status,assessed_at,origin) VALUES(" + "'80000000-0000-4000-8000-000000000018','" EVIDENCE "'," + "'confirmed_forged','" AT "','human');",NULL,NULL,&message),!=,SQLITE_OK); + sqlite3_free(message);message=NULL; + g_assert_cmpint(sqlite3_exec(d,"INSERT INTO person_evidence_factual_relations" + "(id,person_id,evidence_id,relation_type,observed_at,origin) VALUES(" + "'80000000-0000-4000-8000-000000000019','" PERSON "','" EVIDENCE "'," + "'is_author','" AT "','human');",NULL,NULL,&message),!=,SQLITE_OK); + sqlite3_free(message);message=NULL; + g_assert_cmpint(sqlite3_exec(d,"UPDATE identity_field_observations SET " + "confirmed_value='SPECIMEN',confirmation_state='human_confirmed'," + "value_quality='uncertain' WHERE id='" FIELD "';",NULL,NULL,&message),!=,SQLITE_OK); + sqlite3_free(message); + char*raw=scalar(d,"SELECT raw_value FROM identity_field_observations WHERE id='" FIELD "';"); + char*confirmed=scalar(d,"SELECT COALESCE(confirmed_value,'NULL') FROM " + "identity_field_observations WHERE id='" FIELD "';"); + char*auth=scalar(d,"SELECT COUNT(*) FROM document_authenticity_assessments;"); + char*relations=scalar(d,"SELECT COUNT(*) FROM person_evidence_factual_relations;"); + g_assert_cmpstr(raw,==,before);g_assert_cmpstr(confirmed,==,"NULL"); + g_assert_cmpstr(auth,==,"0");g_assert_cmpstr(relations,==,"0"); + exec_ok(d,"UPDATE identity_field_observations SET " + "confirmed_value='CONFIRMÉ SPECIMEN',confirmation_state='human_confirmed'," + "value_quality='partial' WHERE id='" FIELD "';"); + g_free(before);g_free(raw);g_free(confirmed);g_free(auth);g_free(relations); + sqlite3_close(d);f.database=database_open(f.path);g_assert_nonnull(f.database); + g_assert_true(database_migrate_to_latest(f.database)); + IdentityOcrDao*ocr=identity_ocr_dao_new(f.database);GError*error=NULL; + GPtrArray*confirmed_fields=identity_ocr_dao_list_confirmed_fields( + ocr,DOCUMENT,&error);g_assert_no_error(error); + g_assert_cmpuint(confirmed_fields->len,==,1); + IdentityFieldObservationRecord*record=g_ptr_array_index(confirmed_fields,0); + g_assert_cmpstr(record->confirmed_value,==,"CONFIRMÉ SPECIMEN"); + g_ptr_array_unref(confirmed_fields);identity_ocr_dao_free(ocr);fixture_free(&f); +} +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_identification_assessments;" + "DROP TABLE person_evidence_factual_relations;" + "DROP TABLE document_authenticity_assessments;" + "DROP TABLE person_role_vocabulary;DROP TABLE identification_status_vocabulary;" + "ALTER TABLE identity_field_observations RENAME TO identity_fields_v18_old;" + "CREATE TABLE identity_field_observations(id TEXT PRIMARY KEY," + "observation_id TEXT NOT NULL,field_code TEXT NOT NULL,raw_value TEXT," + "corrected_value TEXT,normalized_value TEXT,confidence REAL," + "review_status TEXT NOT NULL,origin TEXT NOT NULL,evidence_id TEXT NOT NULL," + "ocr_run_id TEXT NOT NULL,page_number INTEGER NOT NULL,source_x INTEGER," + "source_y INTEGER,source_width INTEGER,source_height INTEGER," + "source_image_width INTEGER,source_image_height INTEGER,display_order INTEGER," + "reviewed_at TEXT NOT NULL,review_note TEXT);" + "INSERT INTO identity_field_observations SELECT id,observation_id,field_code," + "raw_value,'CORRIGÉ SPECIMEN',normalized_value,confidence,review_status,origin," + "evidence_id,ocr_run_id,page_number,source_x,source_y,source_width,source_height," + "source_image_width,source_image_height,display_order,reviewed_at,review_note " + "FROM identity_fields_v18_old;DROP TABLE identity_fields_v18_old;" + "UPDATE metadata SET value='17' WHERE key='schema_version';" + "CREATE TABLE identification_status_vocabulary(dummy TEXT);"); + sqlite3_close(d);f.database=database_open(f.path);g_assert_nonnull(f.database); + g_test_expect_message(NULL,G_LOG_LEVEL_WARNING, + "*Impossible d’installer la migration SQLite V18*"); + g_assert_false(database_migrate_to_latest(f.database)); + g_test_assert_expected_messages();database_close(f.database); + f.database=NULL;g_assert_cmpint(sqlite3_open(f.path,&d),==,SQLITE_OK); + char*rolled_back=scalar(d, + "SELECT value FROM metadata WHERE key='schema_version';"); + char*partial=scalar(d,"SELECT COUNT(*) FROM sqlite_master WHERE type='table' " + "AND name='document_authenticity_assessments';"); + g_assert_cmpstr(rolled_back,==,"17");g_assert_cmpstr(partial,==,"0"); + g_free(rolled_back);g_free(partial); + exec_ok(d,"DROP TABLE identification_status_vocabulary;"); + sqlite3_close(d);f.database=database_open(f.path);g_assert_nonnull(f.database); + g_assert_true(database_migrate_to_latest(f.database));database_close(f.database); + f.database=NULL;g_assert_cmpint(sqlite3_open(f.path,&d),==,SQLITE_OK); + 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(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)); + fixture_free(&f); +} +int main(int argc,char**argv) +{g_test_init(&argc,&argv,NULL);g_test_add_func("/v18/models",test_models); + g_test_add_func("/v18/dao-history-relations",test_dao_history_and_relations); + g_test_add_func("/v18/sqlite-negative-guards",test_sqlite_negative_guards); + g_test_add_func("/v18/migrate-v17",test_migrate_v17_preserves_ocr); + return g_test_run();} diff --git a/tests/test_person_creation_coordinator.c b/tests/test_person_creation_coordinator.c index 756ddac..98d053c 100644 --- a/tests/test_person_creation_coordinator.c +++ b/tests/test_person_creation_coordinator.c @@ -56,7 +56,8 @@ static void assert_empty_after_reopen(const char *database_path) static const char *const tables[] = { "entites", "preuves", "preuve_entites", "preuve_entite_sources", "person_role_assignments", "identity_ocr_runs", - "identity_document_observations", "identity_field_observations" + "identity_document_observations", "identity_field_observations", + "person_evidence_factual_relations" }; Database *database = database_open(database_path); g_assert_nonnull(database); @@ -100,6 +101,7 @@ static void test_failure_matrix(void) {PERSON_CREATION_FAILURE_CREATE_SOURCE,0}, {PERSON_CREATION_FAILURE_CREATE_SOURCE,1}, {PERSON_CREATION_FAILURE_CREATE_SOURCE,2}, + {PERSON_CREATION_FAILURE_INSERT_FACTUAL_RELATION,0}, {PERSON_CREATION_FAILURE_SESSION_BEFORE_COMMIT,0}, {PERSON_CREATION_FAILURE_ARTIFACT_TEXT_CHANGED,0}, {PERSON_CREATION_FAILURE_ARTIFACT_TSV_CHANGED,0}, @@ -167,11 +169,23 @@ static void test_failure_matrix(void) .confidence = 10, .role_assignments = roles }; + PersonCreationFactualRelationInput factual_relation = { + .evidence_selection_identifier = + person_evidence_selection_item_get_identifier( + person_evidence_selection_get(selection, 0)), + .ocr_run_identifier = identity_ocr_run_get_identifier( + g_ptr_array_index(runs, 0)), + .relation_type = "identity_observed_in", + .factual_note = "Choix humain SPECIMEN" + }; + GPtrArray *factual_relations = g_ptr_array_new(); + g_ptr_array_add(factual_relations, &factual_relation); PersonCreationCoordinatorOptions options = { .failure_point = cases[scenario].point, .failure_occurrence = cases[scenario].occurrence, .inject_compensation_failure = - scenario == G_N_ELEMENTS(cases) - 1 + scenario == G_N_ELEMENTS(cases) - 1, + .factual_relations = factual_relations }; char *ocr_parent=g_build_filename(root,"02_Preuves_Traitees", "OCR",NULL); @@ -223,6 +237,7 @@ static void test_failure_matrix(void) g_free(contents); } g_ptr_array_unref(roles); + g_ptr_array_unref(factual_relations); g_ptr_array_unref(runs); person_evidence_selection_free(selection); g_ptr_array_unref(prepared); @@ -391,9 +406,25 @@ static void test_success_and_rollback(void) g_ptr_array_add(coordinator_runs, coordinator_run); PersonEvidenceSelection *task_selection = person_evidence_selection_copy(selection); + PersonCreationFactualRelationInput explicit_relation = { + .evidence_selection_identifier = + person_evidence_selection_item_get_identifier( + person_evidence_selection_get(task_selection, 0)), + .ocr_run_identifier = + identity_ocr_run_get_identifier(coordinator_run), + .relation_type = "data_extracted_from", + .factual_note = "Choix humain explicite SPECIMEN" + }; + GPtrArray *explicit_relations = g_ptr_array_new(); + g_ptr_array_add(explicit_relations, &explicit_relation); + PersonCreationCoordinatorOptions explicit_options = { + .factual_relations = explicit_relations + }; database = database_open(database_path); - result = person_creation_coordinator_execute(database, root, &person, - task_selection, coordinator_runs, NULL, &error); + result = person_creation_coordinator_execute_with_options( + database, root, &person, task_selection, coordinator_runs, + &explicit_options, NULL, &error); + g_ptr_array_unref(explicit_relations); person_evidence_selection_free(task_selection); g_assert_no_error(error); g_assert_nonnull(result); @@ -405,6 +436,8 @@ static void test_success_and_rollback(void) g_assert_cmpuint(count(database, "identity_ocr_runs"), ==, 1); g_assert_cmpuint(count(database, "identity_document_observations"), ==, 1); g_assert_cmpuint(count(database, "identity_field_observations"), ==, 2); + g_assert_cmpuint(count(database, + "person_evidence_factual_relations"), ==, 1); char *ocr_root = g_build_filename(root, "02_Preuves_Traitees", "OCR", identity_ocr_run_get_identifier(coordinator_run), NULL); char *ocr_text = g_build_filename(ocr_root, "ocr.txt", NULL);