coronelli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. app/__init__.py +0 -0
  2. app/api/__init__.py +0 -0
  3. app/api/admin.py +20 -0
  4. app/api/atlas.py +15 -0
  5. app/api/atlas_edit.py +313 -0
  6. app/api/atlas_export.py +331 -0
  7. app/api/atlas_hierarchy.py +372 -0
  8. app/api/atlas_read.py +240 -0
  9. app/api/atlas_suggestions.py +200 -0
  10. app/api/candidates.py +259 -0
  11. app/api/extraction.py +859 -0
  12. app/api/series.py +120 -0
  13. app/api/sources.py +199 -0
  14. app/api/synthesis.py +194 -0
  15. app/api/workspaces.py +37 -0
  16. app/cli.py +32 -0
  17. app/config.py +20 -0
  18. app/db/__init__.py +0 -0
  19. app/db/base.py +5 -0
  20. app/db/engine.py +22 -0
  21. app/db/migrations/README +1 -0
  22. app/db/migrations/env.py +82 -0
  23. app/db/migrations/script.py.mako +28 -0
  24. app/db/migrations/versions/766ed4afcc51_initial_schema.py +55 -0
  25. app/db/migrations/versions/a1b2c3d4e5f6_extraction_candidates.py +56 -0
  26. app/db/migrations/versions/b2c3d4e5f6a7_candidate_temporal_relation_fields.py +49 -0
  27. app/db/migrations/versions/c3d4e5f6a7b8_extraction_run_cost_and_cache.py +27 -0
  28. app/db/migrations/versions/d4e5f6a7b8c9_synthesis_tables.py +54 -0
  29. app/db/migrations/versions/e5f6a7b8c9d0_map_entity_aliases.py +21 -0
  30. app/db/migrations/versions/f6a7b8c9d0e1_entity_mentions.py +34 -0
  31. app/db/migrations/versions/g7h8i9j0k1l2_source_normalization.py +49 -0
  32. app/db/migrations/versions/h8i9j0k1l2m3_document_author_year.py +23 -0
  33. app/db/migrations/versions/i9j0k1l2m3n4_synthesis_current_phase.py +22 -0
  34. app/db/migrations/versions/j0k1l2m3n4o5_extraction_current_phase.py +22 -0
  35. app/db/migrations/versions/k1l2m3n4o5p6_candidate_source.py +22 -0
  36. app/db/migrations/versions/l2m3n4o5p6q7_global_enrichment_runs.py +34 -0
  37. app/db/models.py +67 -0
  38. app/domain/__init__.py +0 -0
  39. app/domain/review.py +198 -0
  40. app/domain/series.py +131 -0
  41. app/domain/sources.py +157 -0
  42. app/domain/world.py +104 -0
  43. app/evaluation/__init__.py +0 -0
  44. app/evaluation/oracle.py +75 -0
  45. app/events.py +35 -0
  46. app/extraction/__init__.py +0 -0
  47. app/extraction/display.py +71 -0
  48. app/extraction/models.py +70 -0
  49. app/extraction/openai_provider.py +543 -0
  50. app/extraction/prompts.py +78 -0
  51. app/extraction/prompts_catalog.py +283 -0
  52. app/extraction/prompts_evidence.py +437 -0
  53. app/extraction/prompts_taxonomy.py +88 -0
  54. app/extraction/provider.py +37 -0
  55. app/extraction/service.py +477 -0
  56. app/extraction/validation.py +201 -0
  57. app/ingestion/__init__.py +0 -0
  58. app/ingestion/normalizer.py +128 -0
  59. app/ingestion/parsers.py +130 -0
  60. app/ingestion/sectioner.py +420 -0
  61. app/ingestion/service.py +85 -0
  62. app/main.py +61 -0
  63. app/packages/__init__.py +0 -0
  64. app/packages/models.py +8 -0
  65. app/packages/schema_validator.py +27 -0
  66. app/packages/semantic_validator.py +143 -0
  67. app/static/assets/index-BAY1Dwtk.js +4283 -0
  68. app/static/assets/index-CHdClfSm.css +1 -0
  69. app/static/bg/background.png +0 -0
  70. app/static/bg/background2.png +0 -0
  71. app/static/bg/droplet-amber.png +0 -0
  72. app/static/bg/droplet-aqua.png +0 -0
  73. app/static/bg/droplet-blue.png +0 -0
  74. app/static/bg/droplet-green.png +0 -0
  75. app/static/bg/droplet-peach.png +0 -0
  76. app/static/bg/droplet-pink.png +0 -0
  77. app/static/index.html +16 -0
  78. app/synthesis/__init__.py +0 -0
  79. app/synthesis/display.py +49 -0
  80. app/synthesis/models.py +52 -0
  81. app/synthesis/prompts.py +284 -0
  82. app/synthesis/provider.py +516 -0
  83. app/synthesis/review.py +100 -0
  84. app/synthesis/review_approve.py +249 -0
  85. app/synthesis/review_enrich.py +319 -0
  86. app/synthesis/service.py +583 -0
  87. app/workspace/__init__.py +0 -0
  88. app/workspace/schemas.py +24 -0
  89. app/workspace/service.py +71 -0
  90. coronelli-0.1.0.dist-info/METADATA +112 -0
  91. coronelli-0.1.0.dist-info/RECORD +93 -0
  92. coronelli-0.1.0.dist-info/WHEEL +4 -0
  93. coronelli-0.1.0.dist-info/entry_points.txt +2 -0
app/__init__.py ADDED
File without changes
app/api/__init__.py ADDED
File without changes
app/api/admin.py ADDED
@@ -0,0 +1,20 @@
1
+ from fastapi import APIRouter, Depends
2
+ from sqlalchemy.orm import Session
3
+
4
+ from app.db.engine import get_db
5
+ from app.db.models import SourceDocument
6
+ from app.domain.world import EntityMention, MapClaim, MapEntity, MapTravelRule
7
+
8
+ router = APIRouter(tags=["admin"])
9
+
10
+
11
+ @router.delete("/api/admin/reset", status_code=200)
12
+ def reset_library(db: Session = Depends(get_db)) -> dict:
13
+ """Delete everything: all documents and all derived map data."""
14
+ db.query(MapClaim).delete(synchronize_session=False)
15
+ db.query(MapTravelRule).delete(synchronize_session=False)
16
+ db.query(EntityMention).delete(synchronize_session=False)
17
+ db.query(MapEntity).delete(synchronize_session=False)
18
+ db.query(SourceDocument).delete(synchronize_session=False)
19
+ db.commit()
20
+ return {"reset": True}
app/api/atlas.py ADDED
@@ -0,0 +1,15 @@
1
+ from fastapi import APIRouter
2
+
3
+ from app.api.atlas_edit import router as edit_router
4
+ from app.api.atlas_export import router as export_router
5
+ from app.api.atlas_hierarchy import router as hierarchy_router
6
+ from app.api.atlas_read import router as read_router
7
+ from app.api.atlas_suggestions import router as suggestions_router
8
+
9
+ router = APIRouter(tags=["atlas"])
10
+
11
+ router.include_router(read_router)
12
+ router.include_router(export_router)
13
+ router.include_router(edit_router)
14
+ router.include_router(hierarchy_router)
15
+ router.include_router(suggestions_router)
app/api/atlas_edit.py ADDED
@@ -0,0 +1,313 @@
1
+ import uuid
2
+ from datetime import datetime, timezone
3
+
4
+ from fastapi import APIRouter, Depends
5
+ from pydantic import BaseModel
6
+ from sqlalchemy.orm import Session
7
+
8
+ from app.api.atlas_read import ClaimOut, _provenance
9
+ from app.db.engine import get_db
10
+ from app.db.models import SourceSection
11
+ from app.domain.world import MapClaim, MapEntity
12
+ from app.extraction.models import Candidate, ExtractionRun
13
+
14
+ router = APIRouter(tags=["atlas"])
15
+
16
+
17
+ # ── Entity patch / delete ─────────────────────────────────────────────────────
18
+
19
+ class EntityPatchRequest(BaseModel):
20
+ name: str | None = None
21
+ place_kind: str | None = None
22
+ tier: str | None = None
23
+ spatial_level: int | None = None
24
+
25
+
26
+ @router.patch("/api/documents/{document_id}/atlas/entities/{entity_id}")
27
+ def patch_atlas_entity(
28
+ document_id: str, entity_id: str, body: EntityPatchRequest, db: Session = Depends(get_db)
29
+ ):
30
+ from fastapi import HTTPException
31
+ from sqlalchemy.orm.attributes import flag_modified
32
+
33
+ entity = db.get(MapEntity, entity_id)
34
+ if not entity or entity.provenance_document_id != document_id:
35
+ raise HTTPException(status_code=404, detail="Entity not found")
36
+ if body.name is not None:
37
+ new_name = body.name.strip()
38
+ if new_name and new_name != entity.name:
39
+ old_name = entity.name
40
+ entity.name = new_name
41
+ aliases = list(entity.aliases or [])
42
+ if old_name and old_name not in aliases:
43
+ aliases.insert(0, old_name)
44
+ entity.aliases = aliases
45
+ flag_modified(entity, "aliases")
46
+ if body.place_kind is not None:
47
+ entity.place_kind = body.place_kind
48
+ if body.tier is not None:
49
+ payload = dict(entity.payload or {})
50
+ if body.tier:
51
+ payload["tier"] = body.tier
52
+ else:
53
+ payload.pop("tier", None)
54
+ entity.payload = payload
55
+ flag_modified(entity, "payload")
56
+ if body.spatial_level is not None:
57
+ payload = dict(entity.payload or {})
58
+ if body.spatial_level >= 0:
59
+ payload["spatial_level"] = body.spatial_level
60
+ else:
61
+ payload.pop("spatial_level", None)
62
+ entity.payload = payload
63
+ flag_modified(entity, "payload")
64
+ entity.status = "manual"
65
+ db.commit()
66
+ return {"ok": True}
67
+
68
+
69
+ @router.delete("/api/documents/{document_id}/atlas/entities/{entity_id}")
70
+ def delete_atlas_entity(document_id: str, entity_id: str, db: Session = Depends(get_db)):
71
+ from fastapi import HTTPException
72
+
73
+ entity = db.get(MapEntity, entity_id)
74
+ if not entity or entity.provenance_document_id != document_id:
75
+ raise HTTPException(status_code=404, detail="Entity not found")
76
+ entity.state = "deleted"
77
+ for claim in db.query(MapClaim).filter(
78
+ MapClaim.subject_ref == entity_id,
79
+ MapClaim.state == "active",
80
+ ).all():
81
+ claim.state = "deleted"
82
+ for claim in db.query(MapClaim).filter(
83
+ MapClaim.provenance_document_id == document_id,
84
+ MapClaim.state == "active",
85
+ ).all():
86
+ if claim.object_refs and entity_id in claim.object_refs:
87
+ claim.state = "deleted"
88
+ db.commit()
89
+ return {"ok": True}
90
+
91
+
92
+ # ── Claim patch / delete / create ─────────────────────────────────────────────
93
+
94
+ class ClaimPatchRequest(BaseModel):
95
+ predicate: str | None = None
96
+ object_refs: list[str] | None = None
97
+
98
+
99
+ class ClaimCreateRequest(BaseModel):
100
+ claim_type: str = "spatial"
101
+ predicate: str
102
+ subject_ref: str
103
+ object_refs: list[str]
104
+
105
+
106
+ @router.patch("/api/documents/{document_id}/atlas/claims/{claim_id}")
107
+ def patch_atlas_claim(
108
+ document_id: str, claim_id: str, body: ClaimPatchRequest, db: Session = Depends(get_db)
109
+ ):
110
+ from fastapi import HTTPException
111
+
112
+ claim = db.get(MapClaim, claim_id)
113
+ if not claim or claim.provenance_document_id != document_id or claim.state != "active":
114
+ raise HTTPException(status_code=404, detail="Claim not found")
115
+ if body.predicate is not None:
116
+ claim.predicate = body.predicate
117
+ if body.object_refs is not None:
118
+ claim.object_refs = body.object_refs
119
+ claim.status = "manual"
120
+ db.commit()
121
+ return {"ok": True}
122
+
123
+
124
+ @router.delete("/api/documents/{document_id}/atlas/claims/{claim_id}")
125
+ def delete_atlas_claim(document_id: str, claim_id: str, db: Session = Depends(get_db)):
126
+ from fastapi import HTTPException
127
+
128
+ claim = db.get(MapClaim, claim_id)
129
+ if not claim or claim.provenance_document_id != document_id:
130
+ raise HTTPException(status_code=404, detail="Claim not found")
131
+ claim.state = "deleted"
132
+ db.commit()
133
+ return {"ok": True}
134
+
135
+
136
+ @router.post("/api/documents/{document_id}/atlas/claims", response_model=ClaimOut)
137
+ def create_atlas_claim(document_id: str, body: ClaimCreateRequest, db: Session = Depends(get_db)):
138
+ claim = MapClaim(
139
+ id=str(uuid.uuid4()),
140
+ claim_type=body.claim_type,
141
+ predicate=body.predicate,
142
+ subject_ref=body.subject_ref,
143
+ object_refs=body.object_refs,
144
+ provenance_document_id=document_id,
145
+ provenance_section_id=None,
146
+ payload={},
147
+ confidence=1.0,
148
+ excerpt="",
149
+ status="manual",
150
+ state="active",
151
+ )
152
+ db.add(claim)
153
+ db.commit()
154
+ db.refresh(claim)
155
+ return ClaimOut(
156
+ id=claim.id,
157
+ claim_type=claim.claim_type,
158
+ predicate=claim.predicate,
159
+ subject_ref=claim.subject_ref,
160
+ object_refs=claim.object_refs,
161
+ payload=claim.payload,
162
+ confidence=claim.confidence,
163
+ excerpt=claim.excerpt,
164
+ status=claim.status,
165
+ provenance=_provenance(claim.provenance_document_id, claim.provenance_section_id),
166
+ )
167
+
168
+
169
+ # ── Manual entity creation ─────────────────────────────────────────────────────
170
+
171
+ class ManualEntityRequest(BaseModel):
172
+ name: str
173
+ type: str | None = None
174
+ spatial_level: int | None = None
175
+
176
+
177
+ class ManualEntityResponse(BaseModel):
178
+ id: str
179
+ name: str
180
+
181
+
182
+ @router.post("/api/documents/{document_id}/manual-entity", response_model=ManualEntityResponse)
183
+ def add_manual_entity(document_id: str, body: ManualEntityRequest, db: Session = Depends(get_db)):
184
+ from fastapi import HTTPException
185
+
186
+ if not body.name or not body.name.strip():
187
+ raise HTTPException(status_code=422, detail="name is required")
188
+
189
+ first_section = (
190
+ db.query(SourceSection)
191
+ .filter(SourceSection.document_id == document_id)
192
+ .order_by(SourceSection.ordinal)
193
+ .first()
194
+ )
195
+ if not first_section:
196
+ raise HTTPException(status_code=404, detail="Document has no sections")
197
+
198
+ stub_run = (
199
+ db.query(ExtractionRun)
200
+ .filter(
201
+ ExtractionRun.section_id == first_section.id,
202
+ ExtractionRun.status == "manual",
203
+ )
204
+ .first()
205
+ )
206
+ if not stub_run:
207
+ now = datetime.now(timezone.utc)
208
+ stub_run = ExtractionRun(
209
+ section_id=first_section.id,
210
+ status="manual",
211
+ provider="manual",
212
+ model="manual",
213
+ prompt_version="manual-entry",
214
+ started_at=now,
215
+ completed_at=now,
216
+ )
217
+ db.add(stub_run)
218
+ db.flush()
219
+
220
+ payload: dict = {"name": body.name.strip(), "type": body.type or "place", "aliases": []}
221
+ if body.spatial_level is not None:
222
+ payload["spatial_level"] = body.spatial_level
223
+
224
+ candidate = Candidate(
225
+ extraction_run_id=stub_run.id,
226
+ section_id=first_section.id,
227
+ kind="entity",
228
+ payload=payload,
229
+ status="explicit",
230
+ confidence=1.0,
231
+ excerpt="",
232
+ rationale="Manually added by user",
233
+ review_state="approved",
234
+ ordinal=9999,
235
+ source="manual",
236
+ )
237
+ db.add(candidate)
238
+ db.commit()
239
+
240
+ return ManualEntityResponse(id=candidate.id, name=body.name.strip())
241
+
242
+
243
+ # ── Entity merge ───────────────────────────────────────────────────────────────
244
+
245
+ class EntityMergeRequest(BaseModel):
246
+ keep_id: str
247
+ drop_id: str
248
+
249
+
250
+ class EntityMergeResponse(BaseModel):
251
+ merged: bool
252
+ canonical_entity_id: str
253
+ canonical_entity_name: str
254
+ new_aliases: list[str]
255
+
256
+
257
+ @router.post("/api/entities/merge", response_model=EntityMergeResponse)
258
+ def merge_entities(body: EntityMergeRequest, db: Session = Depends(get_db)):
259
+ from fastapi import HTTPException
260
+
261
+ keep = db.get(MapEntity, body.keep_id)
262
+ drop = db.get(MapEntity, body.drop_id)
263
+ if not keep or not drop:
264
+ raise HTTPException(status_code=404, detail="One or both entities not found")
265
+ if keep.id == drop.id:
266
+ raise HTTPException(status_code=422, detail="Cannot merge an entity with itself")
267
+ if keep.state != "active" or drop.state != "active":
268
+ raise HTTPException(status_code=422, detail="Both entities must be active")
269
+
270
+ new_aliases = list(keep.aliases or [])
271
+ for name in [drop.name, *(drop.aliases or [])]:
272
+ if name and name != keep.name and name not in new_aliases:
273
+ new_aliases.append(name)
274
+ keep.aliases = new_aliases
275
+
276
+ db.query(MapClaim).filter(
277
+ MapClaim.subject_ref == body.drop_id,
278
+ MapClaim.state == "active",
279
+ ).update({"subject_ref": body.keep_id}, synchronize_session=False)
280
+
281
+ object_claims = db.query(MapClaim).filter(MapClaim.state == "active").all()
282
+ for c in object_claims:
283
+ if c.object_refs and body.drop_id in c.object_refs:
284
+ c.object_refs = [body.keep_id if r == body.drop_id else r for r in c.object_refs]
285
+
286
+ drop.state = "merged"
287
+
288
+ _existing_same_as = db.query(MapClaim).filter(
289
+ MapClaim.subject_ref == keep.id,
290
+ MapClaim.predicate == "SAME_AS",
291
+ MapClaim.state == "active",
292
+ ).all()
293
+ if not any(c.object_refs and drop.id in c.object_refs for c in _existing_same_as):
294
+ db.add(MapClaim(
295
+ claim_type="spatial",
296
+ subject_ref=keep.id,
297
+ predicate="SAME_AS",
298
+ object_refs=[drop.id],
299
+ status="manual",
300
+ state="active",
301
+ provenance_document_id=keep.provenance_document_id,
302
+ payload={"subject": keep.name, "object": drop.name, "predicate": "SAME_AS"},
303
+ ))
304
+
305
+ db.commit()
306
+ db.refresh(keep)
307
+
308
+ return EntityMergeResponse(
309
+ merged=True,
310
+ canonical_entity_id=keep.id,
311
+ canonical_entity_name=keep.name,
312
+ new_aliases=new_aliases,
313
+ )
@@ -0,0 +1,331 @@
1
+ import re
2
+ import uuid
3
+ from datetime import datetime, timezone
4
+
5
+ from fastapi import APIRouter, Depends
6
+ from fastapi.responses import JSONResponse
7
+ from sqlalchemy.orm import Session
8
+
9
+ from app.api.atlas_read import _discovery, _provenance
10
+ from app.db.engine import get_db
11
+ from app.db.models import SourceDocument, SourceSection
12
+ from app.domain.world import MapClaim, MapEntity, MapTravelRule
13
+ from app.extraction.models import Candidate
14
+
15
+ router = APIRouter(tags=["atlas"])
16
+
17
+
18
+ @router.get("/api/documents/{document_id}/atlas.json")
19
+ def export_atlas_json(document_id: str, db: Session = Depends(get_db)) -> JSONResponse:
20
+ doc = db.get(SourceDocument, document_id)
21
+
22
+ entities = (
23
+ db.query(MapEntity)
24
+ .filter(MapEntity.provenance_document_id == document_id, MapEntity.state == "active")
25
+ .order_by(MapEntity.name)
26
+ .all()
27
+ )
28
+ all_claims = (
29
+ db.query(MapClaim)
30
+ .filter(MapClaim.provenance_document_id == document_id, MapClaim.state == "active")
31
+ .all()
32
+ )
33
+ travel_rules = (
34
+ db.query(MapTravelRule)
35
+ .filter(MapTravelRule.provenance_document_id == document_id, MapTravelRule.state == "active")
36
+ .all()
37
+ )
38
+
39
+ active_entity_ids = {e.id for e in entities}
40
+ entity_by_id = {e.id: e for e in entities}
41
+
42
+ located_in: dict[str, str] = {}
43
+ for claim in sorted(all_claims, key=lambda c: 1 if c.status == "manual" else 0):
44
+ if claim.predicate == "CONTAINS":
45
+ if claim.object_refs and claim.object_refs[0] in active_entity_ids:
46
+ contained_id = claim.object_refs[0]
47
+ if claim.subject_ref in entity_by_id:
48
+ located_in[contained_id] = entity_by_id[claim.subject_ref].name
49
+ elif claim.predicate == "LOCATED_IN" or claim.predicate == "PART_OF":
50
+ if claim.subject_ref in active_entity_ids and claim.object_refs:
51
+ container_id = claim.object_refs[0]
52
+ if container_id in entity_by_id:
53
+ located_in[claim.subject_ref] = entity_by_id[container_id].name
54
+
55
+ _sections = db.query(SourceSection).filter(SourceSection.document_id == document_id).all()
56
+ section_ids = {s.id for s in _sections}
57
+ section_by_id = {s.id: s for s in _sections}
58
+ entity_candidates = (
59
+ db.query(Candidate)
60
+ .filter(
61
+ Candidate.kind == "entity",
62
+ Candidate.status != "rejected",
63
+ Candidate.excerpt != "",
64
+ Candidate.section_id.in_(section_ids),
65
+ )
66
+ .order_by(Candidate.ordinal)
67
+ .all()
68
+ )
69
+ excerpt_by_name: dict[str, str] = {}
70
+ for cand in entity_candidates:
71
+ cname = (cand.payload or {}).get("name", "").lower()
72
+ if cname and cname not in excerpt_by_name and cand.excerpt:
73
+ excerpt_by_name[cname] = cand.excerpt.strip()
74
+
75
+ def _first_excerpt(entity: MapEntity) -> str | None:
76
+ exc = excerpt_by_name.get(entity.name.lower())
77
+ if exc:
78
+ return exc
79
+ for alias in (entity.aliases or []):
80
+ exc = excerpt_by_name.get(alias.lower())
81
+ if exc:
82
+ return exc
83
+ return None
84
+
85
+ def _id_to_name(eid: str | None) -> str | None:
86
+ return entity_by_id[eid].name if eid and eid in entity_by_id else None
87
+
88
+ world_entities = [
89
+ {
90
+ "id": e.id,
91
+ "name": e.name,
92
+ "kind": e.place_kind,
93
+ "kind_descriptor": (e.payload or {}).get("kind_descriptor"),
94
+ "tier": (e.payload or {}).get("tier"),
95
+ "spatial_level": (e.payload or {}).get("spatial_level"),
96
+ "colors": (e.payload or {}).get("colors"),
97
+ "aliases": list(e.aliases or []),
98
+ "located_in": located_in.get(e.id),
99
+ "excerpt": _first_excerpt(e),
100
+ "anchor_count": (e.payload or {}).get("anchor_count", 0),
101
+ "narrative_roles": (e.payload or {}).get("narrative_roles", []),
102
+ "significance": (e.payload or {}).get("significance"),
103
+ "description": (e.payload or {}).get("description"),
104
+ "observations": (e.payload or {}).get("observations", []),
105
+ "first_seen": {
106
+ "section_id": e.provenance_section_id,
107
+ "section_title": section_by_id[e.provenance_section_id].title
108
+ if e.provenance_section_id in section_by_id else None,
109
+ "section_ordinal": section_by_id[e.provenance_section_id].ordinal
110
+ if e.provenance_section_id in section_by_id else None,
111
+ } if e.provenance_section_id else None,
112
+ }
113
+ for e in entities
114
+ ]
115
+
116
+ spatial_claims = []
117
+ visual_claims = []
118
+ for c in all_claims:
119
+ if not c.subject_ref or not c.predicate:
120
+ continue
121
+ object_id = c.object_refs[0] if c.object_refs else None
122
+ if c.claim_type == "spatial":
123
+ spatial_claims.append({
124
+ "id": c.id,
125
+ "subject": _id_to_name(c.subject_ref) or c.payload.get("subject"),
126
+ "predicate": c.predicate,
127
+ "object": _id_to_name(object_id) or c.payload.get("object"),
128
+ "bidirectional": c.payload.get("bidirectional"),
129
+ "confidence": c.confidence,
130
+ "status": c.status,
131
+ "excerpt": c.excerpt,
132
+ })
133
+ elif c.claim_type == "visual":
134
+ visual_claims.append({
135
+ "id": c.id,
136
+ "subject": _id_to_name(c.subject_ref) or c.payload.get("subject"),
137
+ "visual_property": c.payload.get("visual_property") or c.payload.get("category", ""),
138
+ "value": c.payload.get("value") or c.payload.get("observation", ""),
139
+ "section_title": c.payload.get("section_title", ""),
140
+ "palette": c.payload.get("palette"),
141
+ "permanent": c.payload.get("permanent"),
142
+ "view": c.payload.get("view"),
143
+ "confidence": c.confidence,
144
+ "status": c.status,
145
+ })
146
+
147
+ routes = [
148
+ {
149
+ "id": r.id,
150
+ "traveler": r.traveler,
151
+ "from": r.payload.get("from"),
152
+ "to": r.payload.get("to"),
153
+ "via": r.payload.get("via"),
154
+ "can_traverse": r.can_traverse,
155
+ "condition": r.condition,
156
+ "bidirectional": r.payload.get("bidirectional"),
157
+ "transport_mode": r.payload.get("transport_mode"),
158
+ "notes": r.payload.get("notes"),
159
+ }
160
+ for r in travel_rules
161
+ ]
162
+
163
+ package = {
164
+ "format": "coronelli-atlas-v1",
165
+ "exported_at": datetime.now(timezone.utc).isoformat(),
166
+ "document": {
167
+ "id": document_id,
168
+ "title": doc.title if doc else document_id,
169
+ },
170
+ "entities": world_entities,
171
+ "spatial_claims": spatial_claims,
172
+ "visual_claims": visual_claims,
173
+ "routes": routes,
174
+ }
175
+
176
+ raw_slug = re.sub(r'[^a-z0-9]+', '-', (doc.title if doc else document_id).lower()).strip('-')
177
+ filename = f"atlas-{raw_slug[:40]}.json"
178
+ return JSONResponse(
179
+ content=package,
180
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
181
+ )
182
+
183
+
184
+ @router.get("/api/documents/{document_id}/atlas-package")
185
+ def export_atlas_package(document_id: str, db: Session = Depends(get_db)) -> JSONResponse:
186
+ doc = db.get(SourceDocument, document_id)
187
+
188
+ sections = (
189
+ db.query(SourceSection)
190
+ .filter(SourceSection.document_id == document_id)
191
+ .order_by(SourceSection.ordinal)
192
+ .all()
193
+ )
194
+
195
+ entities = (
196
+ db.query(MapEntity)
197
+ .filter(MapEntity.provenance_document_id == document_id, MapEntity.state == "active")
198
+ .order_by(MapEntity.name)
199
+ .all()
200
+ )
201
+ all_claims = (
202
+ db.query(MapClaim)
203
+ .filter(MapClaim.provenance_document_id == document_id, MapClaim.state == "active")
204
+ .all()
205
+ )
206
+ travel_rules = (
207
+ db.query(MapTravelRule)
208
+ .filter(MapTravelRule.provenance_document_id == document_id, MapTravelRule.state == "active")
209
+ .all()
210
+ )
211
+
212
+ entity_by_id: dict[str, MapEntity] = {e.id: e for e in entities}
213
+ entity_id_by_name: dict[str, str] = {e.name.lower(): e.id for e in entities}
214
+ for e in entities:
215
+ for alias in (e.aliases or []):
216
+ if alias.lower() not in entity_id_by_name:
217
+ entity_id_by_name[alias.lower()] = e.id
218
+
219
+ def _name_to_id(name: str | None) -> str | None:
220
+ return entity_id_by_name.get(name.lower()) if name else None
221
+
222
+ def _id_to_name(entity_id: str | None) -> str | None:
223
+ return entity_by_id[entity_id].name if entity_id and entity_id in entity_by_id else None
224
+
225
+ manifest = {
226
+ "package_id": str(uuid.uuid4()),
227
+ "format_version": "atlas-package-v0.1",
228
+ "profile": "single-file-json",
229
+ "exported_at": datetime.now(timezone.utc).isoformat(),
230
+ "generator": "coronelli",
231
+ "entity_count": len(entities),
232
+ "claim_count": len(all_claims),
233
+ "route_count": len(travel_rules),
234
+ "source_count": 1,
235
+ }
236
+
237
+ sources = [
238
+ {
239
+ "document_id": document_id,
240
+ "title": doc.title if doc else document_id,
241
+ "sections": [
242
+ {
243
+ "section_id": s.id,
244
+ "ordinal": s.ordinal,
245
+ "title": s.title,
246
+ }
247
+ for s in sections
248
+ ],
249
+ }
250
+ ]
251
+
252
+ world_entities = []
253
+ for e in entities:
254
+ world_entities.append({
255
+ "id": e.id,
256
+ "name": e.name,
257
+ "type": e.place_kind,
258
+ "aliases": list(e.aliases or []),
259
+ "status": e.status,
260
+ "provenance": _provenance(e.provenance_document_id, e.provenance_section_id),
261
+ "discovery": _discovery(e),
262
+ "notes": e.payload.get("notes"),
263
+ })
264
+
265
+ spatial_claims = []
266
+ visual_claims = []
267
+ for c in all_claims:
268
+ object_id = c.object_refs[0] if c.object_refs else None
269
+ prov = _provenance(c.provenance_document_id, c.provenance_section_id)
270
+
271
+ if c.claim_type == "spatial":
272
+ spatial_claims.append({
273
+ "id": c.id,
274
+ "subject_id": c.subject_ref,
275
+ "subject_name": _id_to_name(c.subject_ref) or c.payload.get("subject"),
276
+ "predicate": c.predicate or c.payload.get("predicate"),
277
+ "object_id": object_id,
278
+ "object_name": _id_to_name(object_id) or c.payload.get("object"),
279
+ "status": c.status,
280
+ "confidence": c.confidence,
281
+ "provenance": prov,
282
+ "excerpt": c.excerpt,
283
+ })
284
+ elif c.claim_type == "visual":
285
+ visual_claims.append({
286
+ "id": c.id,
287
+ "subject_id": c.subject_ref,
288
+ "subject_name": _id_to_name(c.subject_ref) or c.payload.get("subject"),
289
+ "visual_property": c.payload.get("visual_property") or c.payload.get("category", ""),
290
+ "value": c.payload.get("value") or c.payload.get("observation", ""),
291
+ "status": c.status,
292
+ "provenance": prov,
293
+ })
294
+
295
+ world_routes = []
296
+ for r in travel_rules:
297
+ from_name = r.payload.get("from")
298
+ to_name = r.payload.get("to")
299
+ via_name = r.payload.get("via")
300
+ world_routes.append({
301
+ "id": r.id,
302
+ "traveler": r.traveler,
303
+ "from_id": _name_to_id(from_name),
304
+ "from_name": from_name,
305
+ "to_id": _name_to_id(to_name),
306
+ "to_name": to_name,
307
+ "via_id": _name_to_id(via_name),
308
+ "via_name": via_name,
309
+ "can_traverse": r.can_traverse,
310
+ "condition": r.condition,
311
+ "provenance": _provenance(r.provenance_document_id, r.provenance_section_id),
312
+ })
313
+
314
+ package = {
315
+ "format": "atlas-package-v0.1-single",
316
+ "manifest": manifest,
317
+ "sources": sources,
318
+ "world": {
319
+ "entities": world_entities,
320
+ "spatial_claims": spatial_claims,
321
+ "visual_claims": visual_claims,
322
+ "routes": world_routes,
323
+ },
324
+ }
325
+
326
+ slug = (doc.title if doc else document_id)[:32].replace(" ", "-").lower()
327
+ filename = f"atlas-{slug}-v0.1.json"
328
+ return JSONResponse(
329
+ content=package,
330
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
331
+ )