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.
- app/__init__.py +0 -0
- app/api/__init__.py +0 -0
- app/api/admin.py +20 -0
- app/api/atlas.py +15 -0
- app/api/atlas_edit.py +313 -0
- app/api/atlas_export.py +331 -0
- app/api/atlas_hierarchy.py +372 -0
- app/api/atlas_read.py +240 -0
- app/api/atlas_suggestions.py +200 -0
- app/api/candidates.py +259 -0
- app/api/extraction.py +859 -0
- app/api/series.py +120 -0
- app/api/sources.py +199 -0
- app/api/synthesis.py +194 -0
- app/api/workspaces.py +37 -0
- app/cli.py +32 -0
- app/config.py +20 -0
- app/db/__init__.py +0 -0
- app/db/base.py +5 -0
- app/db/engine.py +22 -0
- app/db/migrations/README +1 -0
- app/db/migrations/env.py +82 -0
- app/db/migrations/script.py.mako +28 -0
- app/db/migrations/versions/766ed4afcc51_initial_schema.py +55 -0
- app/db/migrations/versions/a1b2c3d4e5f6_extraction_candidates.py +56 -0
- app/db/migrations/versions/b2c3d4e5f6a7_candidate_temporal_relation_fields.py +49 -0
- app/db/migrations/versions/c3d4e5f6a7b8_extraction_run_cost_and_cache.py +27 -0
- app/db/migrations/versions/d4e5f6a7b8c9_synthesis_tables.py +54 -0
- app/db/migrations/versions/e5f6a7b8c9d0_map_entity_aliases.py +21 -0
- app/db/migrations/versions/f6a7b8c9d0e1_entity_mentions.py +34 -0
- app/db/migrations/versions/g7h8i9j0k1l2_source_normalization.py +49 -0
- app/db/migrations/versions/h8i9j0k1l2m3_document_author_year.py +23 -0
- app/db/migrations/versions/i9j0k1l2m3n4_synthesis_current_phase.py +22 -0
- app/db/migrations/versions/j0k1l2m3n4o5_extraction_current_phase.py +22 -0
- app/db/migrations/versions/k1l2m3n4o5p6_candidate_source.py +22 -0
- app/db/migrations/versions/l2m3n4o5p6q7_global_enrichment_runs.py +34 -0
- app/db/models.py +67 -0
- app/domain/__init__.py +0 -0
- app/domain/review.py +198 -0
- app/domain/series.py +131 -0
- app/domain/sources.py +157 -0
- app/domain/world.py +104 -0
- app/evaluation/__init__.py +0 -0
- app/evaluation/oracle.py +75 -0
- app/events.py +35 -0
- app/extraction/__init__.py +0 -0
- app/extraction/display.py +71 -0
- app/extraction/models.py +70 -0
- app/extraction/openai_provider.py +543 -0
- app/extraction/prompts.py +78 -0
- app/extraction/prompts_catalog.py +283 -0
- app/extraction/prompts_evidence.py +437 -0
- app/extraction/prompts_taxonomy.py +88 -0
- app/extraction/provider.py +37 -0
- app/extraction/service.py +477 -0
- app/extraction/validation.py +201 -0
- app/ingestion/__init__.py +0 -0
- app/ingestion/normalizer.py +128 -0
- app/ingestion/parsers.py +130 -0
- app/ingestion/sectioner.py +420 -0
- app/ingestion/service.py +85 -0
- app/main.py +61 -0
- app/packages/__init__.py +0 -0
- app/packages/models.py +8 -0
- app/packages/schema_validator.py +27 -0
- app/packages/semantic_validator.py +143 -0
- app/static/assets/index-BAY1Dwtk.js +4283 -0
- app/static/assets/index-CHdClfSm.css +1 -0
- app/static/bg/background.png +0 -0
- app/static/bg/background2.png +0 -0
- app/static/bg/droplet-amber.png +0 -0
- app/static/bg/droplet-aqua.png +0 -0
- app/static/bg/droplet-blue.png +0 -0
- app/static/bg/droplet-green.png +0 -0
- app/static/bg/droplet-peach.png +0 -0
- app/static/bg/droplet-pink.png +0 -0
- app/static/index.html +16 -0
- app/synthesis/__init__.py +0 -0
- app/synthesis/display.py +49 -0
- app/synthesis/models.py +52 -0
- app/synthesis/prompts.py +284 -0
- app/synthesis/provider.py +516 -0
- app/synthesis/review.py +100 -0
- app/synthesis/review_approve.py +249 -0
- app/synthesis/review_enrich.py +319 -0
- app/synthesis/service.py +583 -0
- app/workspace/__init__.py +0 -0
- app/workspace/schemas.py +24 -0
- app/workspace/service.py +71 -0
- coronelli-0.1.0.dist-info/METADATA +112 -0
- coronelli-0.1.0.dist-info/RECORD +93 -0
- coronelli-0.1.0.dist-info/WHEEL +4 -0
- 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
|
+
)
|
app/api/atlas_export.py
ADDED
|
@@ -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
|
+
)
|