File size: 8,707 Bytes
86e797b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Small command-line entry point; paths are relative to the working directory."""

import json
from pathlib import Path
from typing import Annotated

import typer

from visual_doc_rag import data

app = typer.Typer(no_args_is_help=True, pretty_exceptions_enable=False)
Subset = Annotated[str, typer.Option(help="Dataset subset: hr or industrial.")]
DataDir = Annotated[Path, typer.Option(help="Local data/cache directory; ignored by Git.")]


def check_subset(subset: str) -> None:
    if subset not in data.SUBSETS:
        raise typer.BadParameter(f"Choose one of: {', '.join(data.SUBSETS)}", param_hint="--subset")


@app.command()
def build_demo(
    subset: Subset = "hr",
    data_dir: DataDir = Path("data"),
    output: Annotated[Path | None, typer.Option(help="New bundle directory.")] = None,
    model: Annotated[
        str, typer.Option(help="Model used by the existing sample cache.")
    ] = "gemini-3.5-flash-lite",
    space: Annotated[bool, typer.Option(help="Include Hugging Face Space configuration.")] = False,
) -> None:
    """Package offline retrieval and five cached samples, without calling the provider."""
    from visual_doc_rag.demo import build_demo as pack

    check_subset(subset)
    try:
        result = pack(subset, data_dir, output, model, space)
    except Exception as error:
        typer.echo(f"Demo build failed: {error}", err=True)
        raise typer.Exit(1) from error
    typer.echo(f"Demo bundle: {result}")


@app.command()
def download(
    subset: Subset = "hr",
    data_dir: DataDir = Path("data"),
    revision: Annotated[
        str, typer.Option(help="HF branch, tag or commit; resolved to a SHA.")
    ] = "main",
) -> None:
    """Download corpus, queries, qrels and document metadata (no original PDFs)."""
    check_subset(subset)
    try:
        manifest = data.download(subset, data_dir, revision)
    except Exception as error:
        typer.echo(f"Download failed: {error}", err=True)
        raise typer.Exit(1) from error
    typer.echo(json.dumps(manifest, indent=2))


@app.command()
def inspect(subset: Subset = "hr", data_dir: DataDir = Path("data")) -> None:
    """Inspect local tables, validate joins and save aggregate statistics/sample pages."""
    check_subset(subset)
    try:
        report = data.inspect(subset, data_dir)
    except Exception as error:
        typer.echo(f"Inspection failed: {error}", err=True)
        raise typer.Exit(1) from error
    typer.echo(json.dumps({k: v for k, v in report.items() if k != "features"}, indent=2))
    typer.echo(f"Saved schema and statistics: {data_dir / subset / 'inspection.json'}")
    if not all(report["integrity_checks"].values()):
        typer.echo("Integrity checks failed; review inspection.json before indexing.", err=True)
        raise typer.Exit(1)


@app.command()
def pages(subset: Subset = "hr", data_dir: DataDir = Path("data")) -> None:
    """Export all original page images to a revision-aware PNG cache."""
    from visual_doc_rag.pages import export_pages

    check_subset(subset)
    try:
        result = export_pages(subset, data_dir)
    except Exception as error:
        typer.echo(f"Page export failed: {error}", err=True)
        raise typer.Exit(1) from error
    typer.echo(f"Exported {len(result['pages'])} pages; {result['image_bytes'] / 1e6:.1f} MB")


@app.command()
def index(
    subset: Subset = "hr",
    data_dir: DataDir = Path("data"),
    device: Annotated[str, typer.Option(help="auto, cuda or cpu for page encoding.")] = "auto",
    mode: Annotated[str, typer.Option(help="all, text or visual.")] = "all",
    model_revision: Annotated[
        str, typer.Option(help="Adapter revision; reuse cached pin by default.")
    ] = "main",
    base_revision: Annotated[
        str | None, typer.Option(help="Optional pinned base-model revision.")
    ] = None,
) -> None:
    """Build BM25 and visual indexes; reuse completed page embeddings on retry."""
    import torch

    from visual_doc_rag.text_index import TextIndex
    from visual_doc_rag.visual_index import build_visual

    check_subset(subset)
    if mode not in {"all", "text", "visual"} or device not in {"auto", "cuda", "cpu"}:
        raise typer.BadParameter("Use --mode all/text/visual and --device auto/cuda/cpu.")
    torch.set_num_threads(4)
    if device == "auto":
        device = "cuda" if torch.cuda.is_available() else "cpu"
    try:
        source, tables = data.load_tables(subset, data_dir)
        if mode in {"all", "text"}:
            result = TextIndex.build(tables["corpus"], data_dir / subset / "indexes", source)
            typer.echo(f"BM25: {json.dumps(result)}")
        if mode in {"all", "visual"}:
            result = build_visual(subset, data_dir, source, device, model_revision, base_revision)
            typer.echo(json.dumps({k: v for k, v in result.items() if k != "pages"}, indent=2))
    except Exception as error:
        typer.echo(f"Index build failed: {error}", err=True)
        raise typer.Exit(1) from error


@app.command(name="eval")
def eval_command(
    subset: Subset = "hr",
    data_dir: DataDir = Path("data"),
    runs_dir: Annotated[
        Path, typer.Option(help="Directory for timestamped metric reports.")
    ] = Path("runs"),
) -> None:
    """Evaluate text/visual/hybrid on every English query, with CPU latency."""
    import torch

    from visual_doc_rag.evaluate import evaluate, format_table

    check_subset(subset)
    torch.set_num_threads(4)
    try:
        output, report = evaluate(subset, data_dir, runs_dir)
    except Exception as error:
        typer.echo(f"Evaluation failed: {error}", err=True)
        raise typer.Exit(1) from error
    typer.echo(format_table(report))
    typer.echo(f"Queries: {report['n_queries']}; pages: {report['n_pages']}")
    typer.echo(f"Saved: {output / 'metrics.json'}")


@app.command()
def search(
    query: Annotated[str, typer.Argument(help="Question or keywords to retrieve pages for.")],
    subset: Subset = "hr",
    data_dir: DataDir = Path("data"),
    method: Annotated[str, typer.Option(help="text, visual or hybrid.")] = "hybrid",
    top_k: Annotated[int, typer.Option(min=1, help="Number of page results to display.")] = 5,
    export: Annotated[
        bool, typer.Option(help="Save an HTML report, page images and JSON under runs/search/.")
    ] = False,
) -> None:
    """Retrieve pages and print local image paths, without generating an answer."""
    from visual_doc_rag.retrieve import PageRetriever

    check_subset(subset)
    if method not in {"text", "visual", "hybrid"} or not query.strip():
        raise typer.BadParameter("Provide a nonempty query and --method text/visual/hybrid.")
    try:
        retriever = PageRetriever(subset, data_dir)
        results = retriever.search(query, method, top_k)
        output = {"method": method, "pages": results}
        if export:
            from visual_doc_rag.search_export import export_search

            output["exported_report"] = str(
                export_search(query, method, retriever.source, results, retriever.tables["corpus"])
            )
    except Exception as error:
        typer.echo(f"Search failed: {error}", err=True)
        raise typer.Exit(1) from error
    typer.echo(json.dumps(output, indent=2))


@app.command()
def ask(
    query: Annotated[str, typer.Argument(help="Question to answer using retrieved page images.")],
    subset: Subset = "hr",
    data_dir: DataDir = Path("data"),
    method: Annotated[str, typer.Option(help="text, visual or hybrid.")] = "hybrid",
    export: Annotated[bool, typer.Option(help="Save an answer and evidence HTML report.")] = False,
) -> None:
    """Answer from the top-3 pages with validated citations (requires GEMINI_API_KEY)."""
    from visual_doc_rag.serve import AnswerService

    check_subset(subset)
    if method not in {"text", "visual", "hybrid"} or not query.strip():
        raise typer.BadParameter("Provide a nonempty query and --method text/visual/hybrid.")
    try:
        service = AnswerService(subset, data_dir)
        output = service.ask(query, method)
        if export:
            from visual_doc_rag.search_export import export_search

            output["exported_report"] = str(
                export_search(
                    output["query"],
                    method,
                    service.retriever.source,
                    output["pages"],
                    service.retriever.tables["corpus"],
                    answer=output,
                )
            )
    except Exception as error:
        typer.echo(f"Answer failed: {error}", err=True)
        raise typer.Exit(1) from error
    typer.echo(json.dumps(output, indent=2, ensure_ascii=False))