File size: 12,085 Bytes
8c1b9fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
"""Tests for visual grounding infrastructure: resolver, ingest metadata, endpoints."""

from __future__ import annotations

import pytest
from fastapi.testclient import TestClient


@pytest.fixture
def client():
    from auralynq.serving.app import create_app

    return TestClient(create_app())


# ---------------------------------------------------------------------------
# Grounding resolver unit tests
# ---------------------------------------------------------------------------


def test_resolver_returns_empty_for_no_citations():
    from auralynq.grounding.resolver import GroundingResolver

    resolver = GroundingResolver()
    results = resolver.resolve(answer_id="test", answer="Hello", citations=[])
    assert results == []


def test_resolver_page_level_grounding():
    """Citation with page number but no bbox → page-level grounding."""
    from auralynq.grounding.resolver import GroundingResolver

    resolver = GroundingResolver()
    citations = [
        {
            "marker": 1,
            "chunk_id": "chunk_001",
            "doc_id": "doc_001",
            "source": "test.pdf",
            "page": 3,
            "score": 0.8,
            "visual_grounding": {
                "grounding_version": 1,
                "page": 3,
                "has_bbox": False,
                "bbox": None,
                "normalized_bbox": None,
            },
        }
    ]
    results = resolver.resolve("test_id", "Some answer [1].", citations)
    assert len(results) == 1
    r = results[0]
    assert r.page == 3
    assert r.doc_id == "doc_001"
    assert r.visual_grounding_available is True
    assert r.grounding_stage == "page"
    assert len(r.highlights) == 1
    ev = r.highlights[0]
    assert ev.support_type == "page"
    assert ev.normalized_bbox is None
    assert ev.reindex_required is False


def test_resolver_span_level_grounding():
    """Citation with bbox → span-level grounding."""
    from auralynq.grounding.resolver import GroundingResolver

    resolver = GroundingResolver()
    citations = [
        {
            "marker": 1,
            "chunk_id": "chunk_002",
            "doc_id": "doc_002",
            "source": "paper.pdf",
            "page": 1,
            "score": 0.9,
            "visual_grounding": {
                "grounding_version": 1,
                "page": 1,
                "has_bbox": True,
                "bbox": [72.0, 100.0, 400.0, 120.0],
                "normalized_bbox": [0.1, 0.13, 0.55, 0.16],
                "block_type": "paragraph",
            },
        }
    ]
    results = resolver.resolve("span_test", "Answer with span [1].", citations)
    assert len(results) == 1
    r = results[0]
    assert r.grounding_stage == "span"
    ev = r.highlights[0]
    assert ev.support_type == "span"
    assert ev.normalized_bbox == [0.1, 0.13, 0.55, 0.16]
    assert ev.bbox == [72.0, 100.0, 400.0, 120.0]


def test_resolver_reindex_required_for_old_doc():
    """Citation with grounding_version=0 → reindex_required=True."""
    from auralynq.grounding.resolver import GroundingResolver

    resolver = GroundingResolver()
    citations = [
        {
            "marker": 1,
            "chunk_id": "old_chunk",
            "doc_id": "old_doc",
            "source": "legacy.pdf",
            "page": None,
            "score": 0.5,
            "visual_grounding": {
                "grounding_version": 0,
                "page": None,
                "has_bbox": False,
            },
        }
    ]
    results = resolver.resolve("old_test", "Answer.", citations)
    assert len(results) == 1
    ev = results[0].highlights[0]
    assert ev.reindex_required is True
    assert ev.support_type == "unavailable"


def test_resolver_to_api_response():
    """to_api_response produces correct structure."""
    from auralynq.grounding.resolver import GroundingResolver

    resolver = GroundingResolver()
    citations = [
        {
            "marker": 1,
            "chunk_id": "c1",
            "doc_id": "d1",
            "source": "doc.pdf",
            "page": 2,
            "score": 0.7,
            "visual_grounding": {
                "grounding_version": 1,
                "page": 2,
                "has_bbox": True,
                "bbox": [0, 0, 100, 20],
                "normalized_bbox": [0.0, 0.0, 0.14, 0.025],
            },
        }
    ]
    results = resolver.resolve("api_test", "The answer is X [1].", citations)
    api_resp = resolver.to_api_response(results)
    assert "highlights" in api_resp
    assert "claim_grounding" in api_resp
    assert "warnings" in api_resp
    assert isinstance(api_resp["visual_grounding_available"], bool)
    assert api_resp["visual_grounding_available"] is True
    assert api_resp["grounding_stage"] == "span"
    assert len(api_resp["highlights"]) == 1
    h = api_resp["highlights"][0]
    assert h["page_image_url"] == "/api/documents/d1/pages/2/image"


def test_resolver_page_image_url_format():
    from auralynq.grounding.resolver import GroundingResolver

    resolver = GroundingResolver()
    citations = [
        {
            "marker": 2,
            "chunk_id": "c2",
            "doc_id": "abc123",
            "source": "file.pdf",
            "page": 5,
            "score": 0.6,
            "visual_grounding": {"grounding_version": 1, "page": 5, "has_bbox": False},
        }
    ]
    results = resolver.resolve("url_test", "Answer.", citations)
    assert results[0].page_image_url == "/api/documents/abc123/pages/5/image"


def test_resolver_claim_grounding_unsupported_without_citations():
    from auralynq.grounding.resolver import GroundingResolver

    resolver = GroundingResolver()
    # Long answer sentences with no citations matching
    citations = [
        {
            "marker": 1,
            "chunk_id": "c3",
            "doc_id": "d3",
            "source": "x.pdf",
            "page": 1,
            "score": 0.5,
            "visual_grounding": {"grounding_version": 1, "page": 1, "has_bbox": False},
        }
    ]
    results = resolver.resolve(
        "claim_test", "This is a long answer sentence. [1] Another unrelated claim here.", citations
    )
    # Claim grounding should exist
    all_claims = [cg for r in results for cg in r.claim_grounding]
    assert len(all_claims) >= 1


# ---------------------------------------------------------------------------
# Ingest model tests
# ---------------------------------------------------------------------------


def test_visual_grounding_version_constant():
    from auralynq.ingest.models import VISUAL_GROUNDING_VERSION

    assert VISUAL_GROUNDING_VERSION == 1


def test_document_has_visual_grounding_property():
    from auralynq.ingest.models import Document, SourceType

    doc = Document(
        id="d1",
        source="test.pdf",
        source_type=SourceType.pdf,
        visual_grounding_version=1,
    )
    assert doc.has_visual_grounding is True

    doc_old = Document(
        id="d2",
        source="old.pdf",
        source_type=SourceType.pdf,
        visual_grounding_version=0,
    )
    assert doc_old.has_visual_grounding is False


def test_layout_block_model():
    from auralynq.ingest.models import LayoutBlock

    blk = LayoutBlock(
        page=1,
        bbox=[72.0, 100.0, 400.0, 120.0],
        normalized_bbox=[0.1, 0.13, 0.55, 0.16],
        text="Sample text",
        block_type="paragraph",
        reading_order=3,
        page_width=720.0,
        page_height=960.0,
    )
    assert blk.page == 1
    assert blk.block_type == "paragraph"
    assert blk.normalized_bbox[0] == pytest.approx(0.1)


# ---------------------------------------------------------------------------
# Settings tests
# ---------------------------------------------------------------------------


def test_visual_grounding_settings_defaults():
    from auralynq.config.settings import VisualGroundingSettings

    vgs = VisualGroundingSettings()
    assert vgs.enabled is True
    assert vgs.page_rendering_enabled is True
    assert vgs.render_dpi == 144
    assert vgs.max_cached_pages == 500
    assert vgs.visual_retrieval_enabled is False
    assert vgs.metadata_version == 1


def test_settings_has_visual():
    import os

    os.environ["AURALYNQ_DOTENV_DISABLED"] = "1"
    from auralynq.config.settings import Settings

    s = Settings(_env_file=None)  # type: ignore
    assert hasattr(s, "visual")
    assert s.visual.enabled is True


# ---------------------------------------------------------------------------
# API endpoint tests
# ---------------------------------------------------------------------------


def test_visual_grounding_settings_endpoint(client):
    resp = client.get("/visual-grounding/settings")
    assert resp.status_code == 200
    data = resp.json()
    assert "enabled" in data
    assert "page_rendering_enabled" in data
    assert "render_dpi" in data
    assert "metadata_version" in data


def test_document_pages_invalid_doc_id_returns_result(client):
    """Valid request format but unknown doc_id → empty result (not error)."""
    resp = client.get("/documents/abc123def456/pages")
    # Should return 200 with empty page list (doc not found = empty metadata)
    assert resp.status_code == 200
    data = resp.json()
    assert "doc_id" in data
    assert "pages" in data


def test_document_page_image_invalid_doc_id_rejected(client):
    """Path traversal attempt should be rejected."""
    resp = client.get("/documents/../etc/passwd/pages/1/image")
    # Either 400, 404, or the path is blocked by FastAPI routing
    assert resp.status_code in (400, 404, 422)


def test_document_page_image_bad_doc_id_rejected(client):
    """Doc ID with invalid chars should return 400."""
    resp = client.get("/documents/../../etc/pages/1/image")
    assert resp.status_code in (400, 404, 422)


def test_document_grounding_status_endpoint(client):
    resp = client.get("/documents/abc123def456/grounding-status")
    assert resp.status_code == 200
    data = resp.json()
    assert "doc_id" in data
    assert "reindex_required" in data
    assert "grounding_available" in data


# ---------------------------------------------------------------------------
# Strategy registry: new experimental strategies
# ---------------------------------------------------------------------------


def test_self_rag_strategy_available():
    from auralynq.rag import get_registry

    registry = get_registry()
    strategies = registry.list_all()
    sr = next(s for s in strategies if s["id"] == "self_rag")
    assert sr["available"] is True
    assert sr["status"] == "experimental"


def test_crag_strategy_available():
    from auralynq.rag import get_registry

    registry = get_registry()
    strategies = registry.list_all()
    crag = next(s for s in strategies if s["id"] == "crag")
    assert crag["available"] is True
    assert crag["status"] == "experimental"


def test_adaptive_strategy_available():
    from auralynq.rag import get_registry

    registry = get_registry()
    strategies = registry.list_all()
    adaptive = next(s for s in strategies if s["id"] == "adaptive")
    assert adaptive["available"] is True
    assert adaptive["status"] == "experimental"


def test_lightrag_local_unavailable():
    from auralynq.rag import get_registry

    registry = get_registry()
    strategies = registry.list_all()
    lr = next(s for s in strategies if s["id"] == "lightrag_local")
    assert lr["available"] is False
    assert lr["unavailable_reason"] is not None


def test_raptor_unavailable():
    from auralynq.rag import get_registry

    registry = get_registry()
    strategies = registry.list_all()
    raptor = next(s for s in strategies if s["id"] == "raptor")
    assert raptor["available"] is False
    assert "hierarchical" in raptor["unavailable_reason"].lower()


def test_all_strategies_have_status_field():
    from auralynq.rag import get_registry

    registry = get_registry()
    for s in registry.list_all():
        assert s["status"] in ("available", "experimental", "planned"), f"{s['id']} has bad status"