File size: 6,073 Bytes
4c151d2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79cf149
4c151d2
 
 
 
79cf149
4c151d2
e8f6f4d
 
79cf149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4c151d2
 
 
 
 
 
 
 
 
79cf149
 
 
 
 
 
4c151d2
 
 
 
79cf149
4c151d2
 
 
79cf149
4c151d2
 
 
 
 
79cf149
4c151d2
79cf149
 
 
4c151d2
79cf149
4c151d2
 
 
 
79cf149
 
 
 
 
 
 
 
 
 
 
 
4c151d2
79cf149
4c151d2
 
 
 
 
 
 
79cf149
4c151d2
 
 
 
 
 
 
 
 
 
79cf149
4c151d2
 
 
 
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
"""Command-line entry point for the document ingestion pipeline.

Examples:
    Ingest the latest annual report, transcripts and decks for one ticker::

        python -m scripts.ingest_documents WIPRO

    Three years of filings, transcripts only, with a fresh parse::

        python -m scripts.ingest_documents WIPRO --annual-reports 3 \\
            --doc-types concall_transcript --force parse

    Show what is already ingested without touching the network::

        python -m scripts.ingest_documents WIPRO --status

Google Python Style Guide Compliant.
"""

import argparse
import json
import logging
from pathlib import Path
import sys
from typing import List, Optional, Sequence

# Allow execution as a script from the project root as well as with -m.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from core.config import OUTPUT_DIR  # noqa: E402
from ingestion.chunker import DEFAULT_ANNUAL_SKIP_SECTIONS  # noqa: E402
from ingestion.fetcher import DOC_TYPES, STAGES  # noqa: E402
from ingestion.rag import InstitutionalRAGPipeline  # noqa: E402
from scripts import cli  # noqa: E402


def _parser() -> argparse.ArgumentParser:
    """Builds the argument parser."""
    p = argparse.ArgumentParser(
        description="Ingest a company's filings into the document registry.")
    p.add_argument("ticker", help="Stock ticker symbol, e.g. WIPRO")
    p.add_argument("--data-dir", help="Root artifact directory (default: %s)" % OUTPUT_DIR)
    p.add_argument("--annual-reports", type=int, default=1,
                   help="Annual report years to request (default: 1)")
    p.add_argument("--concall-years", type=int, default=1,
                   help="Years of concalls to request (default: 1)")
    p.add_argument("--doc-types", nargs="*", choices=DOC_TYPES,
                   help="Document classes to ingest (default: all)")
    p.add_argument("--embed-model", default=DEFAULT_EMBED_MODEL,
                   help="Embedding model identifier (default: %(default)s)")
    p.add_argument("--force", nargs="*", choices=STAGES,
                   help="Stages to run regardless of recorded state")
    p.add_argument("--no-prompts", action="store_true",
                   help="Skip building extraction prompts")
    p.add_argument("--max-pages", type=int,
                   help="Cap pages parsed per document (smoke tests)")
    p.add_argument("--skip-sections", nargs="*",
                   help="Annual-report sections to leave out of chunking. Defaults "
                        "to %s. Pass the flag with no values to embed every "
                        "section, including the audited statements."
                        % " ".join(DEFAULT_ANNUAL_SKIP_SECTIONS))
    p.add_argument("--status", action="store_true",
                   help="Print the registry's state and exit")
    p.add_argument("--json", action="store_true", help="Print the run summary as JSON")
    p.add_argument("--verbose", action="store_true", help="Debug logging")
    return p


def _grid(header: Sequence[str], rows: Sequence[Sequence[str]]) -> str:
    """Renders a padded text table, sized to its widest cell per column."""
    widths = [max(len(str(cell)) for cell in column)
              for column in zip(header, *rows)] if rows else [len(h) for h in header]
    widths = [max(width, 8) for width in widths]

    def line(cells: Sequence[str]) -> str:
        return "  ".join(str(cell).ljust(widths[i]) for i, cell in enumerate(cells))

    return "\n".join([line(header), "  ".join("-" * w for w in widths)]
                     + [line(row) for row in rows])


def _print_status(registry: DocumentRegistry) -> None:
    """Prints one row per catalogued document and its stage states."""
    rows = registry.status_table()
    if not rows:
        print("No documents catalogued for %s yet." % registry.ticker)
        return

    header = ["doc_id", "label"] + list(STAGES) + ["chunks", "embed_model"]
    table = [[row["doc_id"], row["label"]]
             + [str(row.get(stage, "-")) for stage in STAGES]
             + [str(row.get("chunks", 0)), str(row.get("embed_model", ""))]
             for row in rows]
    print(_grid(header, table))

    counts = registry.counts()
    print("\n%s: %d documents; %s" % (
        registry.ticker, counts["documents"],
        ", ".join("%s=%d" % (stage, counts[stage]) for stage in STAGES)))


def main(argv: Optional[List[str]] = None) -> int:
    """Runs the pipeline for one ticker.

    Args:
        argv: Argument list, defaulting to sys.argv[1:].

    Returns:
        0 on success, 1 when any document failed a stage.
    """
    args = _parser().parse_args(argv)
    cli.setup(logging.DEBUG if args.verbose else logging.INFO,
              cli.TIMED, datefmt=cli.CLOCK)

    data_dir = Path(args.data_dir) if args.data_dir else Path(OUTPUT_DIR)
    if args.status:
        _print_status(DocumentRegistry(args.ticker, data_dir))
        return 0

    pipeline = IngestionPipeline(
        args.ticker,
        data_dir=data_dir,
        annual_reports=args.annual_reports,
        concall_years=args.concall_years,
        doc_types=args.doc_types,
        embed_model=args.embed_model,
        force=args.force,
        build_prompts=not args.no_prompts,
        max_pages=args.max_pages,
        skip_sections=args.skip_sections,
    )
    state = pipeline.run()
    errors = state.get("errors") or []

    if args.json:
        print(json.dumps({
            "ticker": state.get("ticker"),
            "company_name": state.get("company_name"),
            "planned": state.get("planned"),
            "actions": state.get("actions"),
            "errors": errors,
            "counts": pipeline.registry.counts(),
        }, indent=2))
    else:
        print()
        _print_status(pipeline.registry)
        if errors:
            print("\n%d failure(s):" % len(errors))
            for item in errors:
                print("  %-28s %-9s %s" % (item["doc_id"], item["stage"], item["error"]))

    return 1 if errors else 0


if __name__ == "__main__":
    raise SystemExit(main())