personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

apps/import: render merge summary, principal-collision callout, and decision highlights in detail view

- Adds merge summary card, principal-collision callout, and decision-log highlights to the import detail Overview, gated on imported_json.merge_summary.
- New route-level tests cover source registration, detail-payload shape with and without merge fields, and guide endpoint.

Co-Authored-By: OpenAI Codex <codex@openai.com>

+316
+94
apps/import/_detail.html
··· 51 51 .info-label { font-weight: bold; color: #666; } 52 52 .info-value { color: #333; } 53 53 54 + .merge-summary-card { grid-column: 1 / -1; margin-top: 1rem; padding: 1rem; background: #f7f9fc; border: 1px solid var(--facet-border, #e5e0db); border-radius: 8px; } 55 + .merge-summary-card h3 { margin: 0 0 0.75rem; font-size: 1rem; } 56 + .merge-summary-grid { display: grid; gap: 0.65rem; } 57 + .merge-summary-row { display: grid; grid-template-columns: minmax(7rem, auto) 1fr; gap: 0.75rem; align-items: baseline; } 58 + .merge-summary-label { font-weight: 600; color: #555; } 59 + .merge-summary-value { color: #222; } 60 + .merge-collision-callout { grid-column: 1 / -1; margin-top: 1rem; padding: 0.9rem 1rem; border-left: 4px solid #d97706; background: #fff7ed; border-radius: 6px; } 61 + .merge-collision-callout h3 { margin: 0 0 0.35rem; font-size: 0.98rem; color: #9a3412; } 62 + .merge-collision-callout p { margin: 0; color: #7c2d12; } 63 + .merge-highlights { grid-column: 1 / -1; margin-top: 1rem; padding: 1rem; border: 1px solid var(--facet-border, #e5e0db); border-radius: 8px; background: #fff; } 64 + .merge-highlights h3 { margin: 0 0 0.75rem; font-size: 1rem; } 65 + .merge-highlights-section + .merge-highlights-section { margin-top: 0.9rem; } 66 + .merge-highlights-section h4 { margin: 0 0 0.4rem; font-size: 0.92rem; color: #555; } 67 + .merge-highlights-list { margin: 0; padding-left: 1.25rem; color: #333; } 68 + .merge-highlights-list li + li { margin-top: 0.35rem; } 69 + .merge-artifact-paths { margin-top: 0.9rem; font-size: 0.9rem; color: #555; } 70 + .merge-artifact-paths code { display: inline-block; margin-top: 0.15rem; background: #f3f4f6; border-radius: 4px; padding: 0.1rem 0.35rem; color: #222; } 71 + 54 72 .no-data { color: #999; font-style: italic; padding: 2em; text-align: center; } 55 73 56 74 /* Content tab */ ··· 511 529 overviewHtml += `<div class="info-label">Target Day:</div><div class="info-value">${data.imported_json.target_day || '-'}</div>`; 512 530 overviewHtml += `<div class="info-label">Files Created:</div><div class="info-value">${data.imported_json.total_files_created || 0}</div>`; 513 531 overviewHtml += `<div class="info-label">Processing Time:</div><div class="info-value">${new Date(data.imported_json.processing_completed).toLocaleString()}</div>`; 532 + 533 + if (data.imported_json.merge_summary) { 534 + const mergeSummary = data.imported_json.merge_summary; 535 + overviewHtml += ` 536 + <div class="merge-summary-card"> 537 + <h3>Merge Summary</h3> 538 + <div class="merge-summary-grid"> 539 + <div class="merge-summary-row"> 540 + <div class="merge-summary-label">Segments</div> 541 + <div class="merge-summary-value">${mergeSummary.segments_copied || 0} copied · ${mergeSummary.segments_skipped || 0} skipped · ${mergeSummary.segments_errored || 0} errored</div> 542 + </div> 543 + <div class="merge-summary-row"> 544 + <div class="merge-summary-label">Entities</div> 545 + <div class="merge-summary-value">${mergeSummary.entities_created || 0} created · ${mergeSummary.entities_merged || 0} merged · ${mergeSummary.entities_staged || 0} staged</div> 546 + </div> 547 + <div class="merge-summary-row"> 548 + <div class="merge-summary-label">Facets</div> 549 + <div class="merge-summary-value">${mergeSummary.facets_created || 0} created · ${mergeSummary.facets_merged || 0} merged</div> 550 + </div> 551 + <div class="merge-summary-row"> 552 + <div class="merge-summary-label">Imports</div> 553 + <div class="merge-summary-value">${mergeSummary.imports_copied || 0} copied · ${mergeSummary.imports_skipped || 0} skipped</div> 554 + </div> 555 + </div> 556 + </div> 557 + `; 558 + 559 + if (data.imported_json.principal_collision) { 560 + const principalCollision = data.imported_json.principal_collision; 561 + overviewHtml += ` 562 + <div class="merge-collision-callout"> 563 + <h3>Owner identity differs between journals</h3> 564 + <p>This journal belongs to ${escapeHtml(principalCollision.target_name || '')}, and the imported archive marks ${escapeHtml(principalCollision.source_name || '')} as the owner. Solstone kept this journal&#39;s owner and brought the other person in as a regular entity.</p> 565 + </div> 566 + `; 567 + } 568 + 569 + const stagedEntities = data.decision_highlights?.staged_entities || []; 570 + const erroredSegments = data.decision_highlights?.errored_segments || []; 571 + const summaryErrors = data.summary_errors || []; 572 + const artifactPaths = data.merge_artifact_paths || null; 573 + if (stagedEntities.length || erroredSegments.length || summaryErrors.length || artifactPaths) { 574 + let highlightsHtml = '<div class="merge-highlights"><h3>Merge Highlights</h3>'; 575 + if (stagedEntities.length) { 576 + highlightsHtml += '<div class="merge-highlights-section"><h4>Staged entities</h4><ul class="merge-highlights-list">'; 577 + stagedEntities.forEach(item => { 578 + highlightsHtml += `<li>${escapeHtml(item.source_name || '')} → ${escapeHtml(item.target_name || '')}<br><code>${escapeHtml(item.staging_path || '')}</code></li>`; 579 + }); 580 + highlightsHtml += '</ul></div>'; 581 + } 582 + if (erroredSegments.length) { 583 + highlightsHtml += '<div class="merge-highlights-section"><h4>Errored segments</h4><ul class="merge-highlights-list">'; 584 + erroredSegments.forEach(item => { 585 + highlightsHtml += `<li><strong>${escapeHtml(item.item_id || '')}</strong>: ${escapeHtml(item.reason || '')}</li>`; 586 + }); 587 + highlightsHtml += '</ul></div>'; 588 + } 589 + if (summaryErrors.length) { 590 + highlightsHtml += '<div class="merge-highlights-section"><h4>Summary errors</h4><ul class="merge-highlights-list">'; 591 + summaryErrors.forEach(item => { 592 + highlightsHtml += `<li>${escapeHtml(item || '')}</li>`; 593 + }); 594 + highlightsHtml += '</ul></div>'; 595 + } 596 + if (artifactPaths) { 597 + highlightsHtml += ` 598 + <div class="merge-artifact-paths"> 599 + <div>Decisions log: <code>${escapeHtml(artifactPaths.decisions || '')}</code></div> 600 + <div>Staging: <code>${escapeHtml(artifactPaths.staging || '')}</code></div> 601 + </div> 602 + `; 603 + } 604 + highlightsHtml += '</div>'; 605 + overviewHtml += highlightsHtml; 606 + } 607 + } 514 608 515 609 // Show quick links 516 610 if (data.imported_json.target_day) {
+222
tests/test_app_import_journal.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + import json 5 + from pathlib import Path 6 + 7 + import pytest 8 + 9 + from convey import create_app 10 + 11 + 12 + @pytest.fixture(autouse=True) 13 + def _temp_journal(monkeypatch, tmp_path): 14 + journal_root = tmp_path / "journal" 15 + journal_root.mkdir() 16 + config_dir = journal_root / "config" 17 + config_dir.mkdir() 18 + (config_dir / "journal.json").write_text( 19 + json.dumps( 20 + { 21 + "setup": {"completed_at": "2026-04-26T00:00:00Z"}, 22 + "convey": {"trust_localhost": True}, 23 + } 24 + ), 25 + encoding="utf-8", 26 + ) 27 + monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) 28 + return journal_root 29 + 30 + 31 + @pytest.fixture 32 + def client(_temp_journal): 33 + app = create_app(str(_temp_journal)) 34 + return app.test_client() 35 + 36 + 37 + def _write_import_detail( 38 + journal_root: Path, 39 + timestamp: str, 40 + *, 41 + imported_json: dict | None = None, 42 + import_json: dict | None = None, 43 + ) -> Path: 44 + import_dir = journal_root / "imports" / timestamp 45 + import_dir.mkdir(parents=True) 46 + if import_json is not None: 47 + (import_dir / "import.json").write_text( 48 + json.dumps(import_json), 49 + encoding="utf-8", 50 + ) 51 + if imported_json is not None: 52 + (import_dir / "imported.json").write_text( 53 + json.dumps(imported_json), 54 + encoding="utf-8", 55 + ) 56 + return import_dir 57 + 58 + 59 + def test_import_sources_include_journal_archive(client): 60 + response = client.get("/app/import/api/sources") 61 + 62 + assert response.status_code == 200 63 + data = response.get_json() 64 + journal_source = next(item for item in data if item["name"] == "journal_archive") 65 + assert journal_source["display_name"] == "Journal" 66 + assert journal_source["emoji"] == "📓" 67 + assert journal_source["accept"] == ".zip" 68 + assert journal_source["has_guide"] is True 69 + assert journal_source["input_type"] == "file" 70 + 71 + 72 + def test_import_detail_api_includes_merge_fields(client, _temp_journal): 73 + timestamp = "20260426_120000" 74 + merge_root = ( 75 + _temp_journal.parent / f"{_temp_journal.name}.merge" / "20260426T120000Z" 76 + ) 77 + decisions_path = merge_root / "decisions.jsonl" 78 + staging_path = merge_root / "staging" 79 + decisions_path.parent.mkdir(parents=True) 80 + decisions_path.write_text( 81 + "\n".join( 82 + [ 83 + json.dumps( 84 + { 85 + "action": "entity_staged", 86 + "item_id": "source_person", 87 + "source": {"name": "Source Person"}, 88 + "target": {"name": "Target Person"}, 89 + "staging_path": str( 90 + staging_path / "source_person" / "entity.json" 91 + ), 92 + } 93 + ), 94 + json.dumps( 95 + { 96 + "action": "segment_errored", 97 + "item_id": "20260101/default/090000_300", 98 + "reason": "segment copy failed", 99 + } 100 + ), 101 + ] 102 + ) 103 + + "\n", 104 + encoding="utf-8", 105 + ) 106 + _write_import_detail( 107 + _temp_journal, 108 + timestamp, 109 + import_json={"original_filename": "journal.zip"}, 110 + imported_json={ 111 + "source_type": "journal_archive", 112 + "merge_summary": {"segments_copied": 1}, 113 + "merge_log_path": str(decisions_path), 114 + "merge_staging_path": str(staging_path), 115 + "summary_errors": [ 116 + "segment 20260101/default/090000_300: segment copy failed" 117 + ], 118 + "principal_collision": { 119 + "source_name": "Source Person", 120 + "target_name": "Target Person", 121 + }, 122 + }, 123 + ) 124 + 125 + response = client.get(f"/app/import/api/{timestamp}") 126 + 127 + assert response.status_code == 200 128 + data = response.get_json() 129 + assert data["merge_artifact_paths"] == { 130 + "decisions": str(decisions_path), 131 + "staging": str(staging_path), 132 + } 133 + assert data["decision_highlights"] == { 134 + "staged_entities": [ 135 + { 136 + "source_name": "Source Person", 137 + "target_name": "Target Person", 138 + "staging_path": str(staging_path / "source_person" / "entity.json"), 139 + } 140 + ], 141 + "errored_segments": [ 142 + { 143 + "item_id": "20260101/default/090000_300", 144 + "reason": "segment copy failed", 145 + } 146 + ], 147 + } 148 + assert data["summary_errors"] == [ 149 + "segment 20260101/default/090000_300: segment copy failed" 150 + ] 151 + assert data["imported_json"]["principal_collision"] == { 152 + "source_name": "Source Person", 153 + "target_name": "Target Person", 154 + } 155 + 156 + 157 + def test_import_detail_api_omits_decision_highlights_without_qualifying_rows( 158 + client, _temp_journal 159 + ): 160 + timestamp = "20260426_120001" 161 + merge_root = ( 162 + _temp_journal.parent / f"{_temp_journal.name}.merge" / "20260426T120001Z" 163 + ) 164 + decisions_path = merge_root / "decisions.jsonl" 165 + staging_path = merge_root / "staging" 166 + decisions_path.parent.mkdir(parents=True) 167 + decisions_path.write_text( 168 + json.dumps( 169 + { 170 + "action": "segment_copied", 171 + "item_id": "20260101/default/090000_300", 172 + "reason": "new", 173 + } 174 + ) 175 + + "\n", 176 + encoding="utf-8", 177 + ) 178 + _write_import_detail( 179 + _temp_journal, 180 + timestamp, 181 + imported_json={ 182 + "source_type": "journal_archive", 183 + "merge_summary": {"segments_copied": 1}, 184 + "merge_log_path": str(decisions_path), 185 + "merge_staging_path": str(staging_path), 186 + }, 187 + ) 188 + 189 + response = client.get(f"/app/import/api/{timestamp}") 190 + 191 + assert response.status_code == 200 192 + data = response.get_json() 193 + assert "decision_highlights" not in data 194 + 195 + 196 + def test_import_detail_api_omits_merge_fields_for_non_merge_import( 197 + client, _temp_journal 198 + ): 199 + timestamp = "20260426_120002" 200 + _write_import_detail( 201 + _temp_journal, 202 + timestamp, 203 + imported_json={ 204 + "source_type": "chatgpt", 205 + "total_files_created": 2, 206 + }, 207 + ) 208 + 209 + response = client.get(f"/app/import/api/{timestamp}") 210 + 211 + assert response.status_code == 200 212 + data = response.get_json() 213 + assert "merge_artifact_paths" not in data 214 + assert "decision_highlights" not in data 215 + assert "summary_errors" not in data 216 + 217 + 218 + def test_import_guide_for_journal_archive(client): 219 + response = client.get("/app/import/api/guide/journal_archive") 220 + 221 + assert response.status_code == 200 222 + assert "# Exporting Your Journal" in response.get_data(as_text=True)