Spaces:
Running on Zero
Running on Zero
File size: 8,615 Bytes
7f09bf5 ee5650f 7f09bf5 ee5650f 7f09bf5 ee5650f 7f09bf5 79cf149 7f09bf5 79cf149 ee5650f 79cf149 ee5650f 7f09bf5 ee5650f 7f09bf5 ee5650f 7f09bf5 ee5650f 7f09bf5 ee5650f 7f09bf5 79cf149 7f09bf5 79cf149 7f09bf5 79cf149 7f09bf5 2ac7dee 7f09bf5 2ac7dee 2a9a5f3 7f09bf5 2a9a5f3 7f09bf5 79cf149 7f09bf5 ee5650f 7f09bf5 ee5650f 2ac7dee ee5650f 2ac7dee ee5650f 7f09bf5 ee5650f 2ac7dee ee5650f 2ac7dee ee5650f | 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 | """Report orchestration: snapshot to compiled PDF.
Charts and the generated Typst source are written into the stock's own
directory, because Typst resolves `#image` paths relative to the source file.
Once the PDF compiles they are swept away: they are reproducible from the
cached payloads, and what a reader wants in that directory is the report.
"""
from datetime import date
import logging
from pathlib import Path
import shutil
from typing import Optional, Tuple
import typst
from reporting import analytics
from reporting import charts as charts_module
from reporting import composites as composites_module
from reporting import selfcheck
from reporting import typst_doc
from core.config import OUTPUT_DIR, safe_ticker
from reporting.client import CollectorClient
from reporting.snapshot import build_snapshot
logger = logging.getLogger(__name__)
# The API server, the CLI and the ingestion layer all write into the same
# per-stock directory, so the root and the folding rule are owned by config.
DEFAULT_OUTPUT_DIR: Path = OUTPUT_DIR
# Byproducts of a build: the chart SVGs the document referenced, the generated
# Typst source, and any rendered page previews. Once the PDF exists these are
# reproducible from the cached payloads, so they are swept away.
#
# Only these. The same directory holds the ingestion layer's caches and the
# Drive upload record, which belong to other layers: clearing those would
# re-download and re-embed a company's filings, and re-upload a report that
# had not changed.
BUILD_GLOBS: Tuple[str, ...] = ("*.svg", "*.typ")
PREVIEW_DIR: str = "preview"
class ReportError(RuntimeError):
"""Raised when a report cannot be produced."""
def generate_report(
ticker: str,
output_dir: Optional[Path] = None,
refresh: bool = False,
as_of: Optional[str] = None,
keep_build: bool = False,
) -> Path:
"""Builds the institutional PDF report for one ticker.
Args:
ticker: Stock ticker symbol, e.g. 'WIPRO'.
output_dir: Root holding one directory per stock. Defaults to
`output/`.
refresh: Re-request collector data instead of using the cache.
as_of: Display date for the header and disclaimer. Defaults to today.
keep_build: Retain the chart SVGs and the generated `.typ` instead of
sweeping them, which makes a layout problem inspectable after the
fact. A failed compile keeps them regardless.
Returns:
Path to the written PDF, in the stock's own directory.
Raises:
ReportError: If the snapshot is too sparse to report on, or Typst
fails to compile the generated source.
"""
# The unfolded symbol addresses the collector; the folded one addresses
# the filesystem, because Indian symbols carry ampersands.
symbol = ticker.upper().strip()
folded = safe_ticker(symbol)
root = Path(output_dir) if output_dir else DEFAULT_OUTPUT_DIR
work_dir = root / folded
work_dir.mkdir(parents=True, exist_ok=True)
from concurrent.futures import ThreadPoolExecutor
# Define Track 1: Quantitative statements, ratios, composites, self-check & charts
def _run_quantitative_track():
logger.info("[%s] [Track 1] Fetching collector data & computing analytics...", symbol)
client = CollectorClient(cache_dir=root)
payloads = client.fetch_all(symbol, refresh=refresh)
snap = build_snapshot(symbol, payloads)
if not snap.years and not snap.quarters:
raise ReportError(
"No income statement data for %s; refusing to render an empty report" % symbol
)
for warning in snap.warnings:
logger.warning("[%s] %s", symbol, warning)
der = analytics.compute(snap)
for note in der.notes:
logger.info("[%s] %s", symbol, note)
comp = composites_module.compute(snap, der)
for note in comp.notes:
logger.info("[%s] %s", symbol, note)
chk = selfcheck.run(snap, der, comp)
for failure in chk.failures:
logger.error("[%s] self-check failed: %s (residual %s %s)",
symbol, failure.name, failure.delta, failure.unit)
logger.info("[%s] [Track 1] Rendering charts...", symbol)
prod = charts_module.render_all(snap, der, comp, work_dir)
return snap, der, comp, chk, prod
# Define Track 2: Qualitative RAG research synthesis
def _run_qualitative_track():
findings_path = work_dir / "findings" / "findings.json"
findings_data = None
if not findings_path.exists() or refresh:
try:
logger.info("[%s] [Track 2] Running RAG pipeline to generate institutional findings...", symbol)
from ingestion.rag.pipeline import extract_ticker_findings
dossier = extract_ticker_findings(
ticker=symbol,
company_name=symbol,
force=refresh,
)
findings_data = dossier.to_dict()
logger.info("[%s] [Track 2] Qualitative RAG synthesis complete (%d pillars).", symbol, len(dossier.pillars))
except Exception as exc:
logger.warning("[%s] RAG qualitative extraction skipped: %s", symbol, exc)
if findings_path.exists() and not findings_data:
try:
import json
findings_data = json.loads(findings_path.read_text(encoding="utf-8"))
logger.info("[%s] Loaded qualitative research findings from %s", symbol, findings_path)
except Exception as exc:
logger.warning("[%s] Could not load qualitative findings: %s", symbol, exc)
return findings_data
# Execute Track 1 and Track 2 concurrently
with ThreadPoolExecutor(max_workers=2, thread_name_prefix=f"report-{symbol}") as executor:
f_quant = executor.submit(_run_quantitative_track)
f_qual = executor.submit(_run_qualitative_track)
snapshot, derived, composites, check, produced = f_quant.result()
findings_data = f_qual.result()
stamp = as_of or date.today().strftime("%d %b %Y")
source = typst_doc.build_document(
snapshot, derived, composites, check, produced, as_of=stamp, findings=findings_data
)
source_path = work_dir / (folded + "_report.typ")
source_path.write_text(source, encoding="utf-8")
logger.info("[%s] compiling %d chars of Typst source", symbol, len(source))
try:
pdf_bytes = typst.compile(str(source_path))
except Exception as exc:
raise ReportError(
"Typst compilation failed for %s. Source retained at %s.\n%s"
% (symbol, source_path, exc)
) from exc
pdf_path = work_dir / (folded + "_report.pdf")
pdf_path.write_bytes(pdf_bytes)
logger.info("[%s] wrote %s (%.1f KB)", symbol, pdf_path, pdf_path.stat().st_size / 1024)
# Automatically upload / mirror report to Google Drive if credentials exist
try:
from storage.gdrive import credentials_present, ensure_uploaded
if credentials_present():
logger.info("[%s] uploading / updating report on Google Drive...", symbol)
drive_file = ensure_uploaded(pdf_path, symbol, force=refresh)
logger.info("[%s] Google Drive update complete: %s", symbol, drive_file.view_link)
except Exception as exc:
logger.warning("[%s] Google Drive update skipped: %s", symbol, exc)
if not keep_build:
swept = _sweep_build(work_dir, pdf_path)
logger.info("[%s] swept %d build artefact(s); the PDF and findings are what remain",
symbol, swept)
return pdf_path
def _sweep_build(work_dir: Path, pdf_path: Path) -> int:
"""Removes the build byproducts and temporary files from a stock's directory.
Args:
work_dir: The stock's directory.
pdf_path: The report, which is never removed.
Returns:
How many entries were removed.
"""
removed = 0
patterns = list(BUILD_GLOBS) + ["*.tmp", "*.temp"]
for pattern in patterns:
for path in work_dir.glob(pattern):
if path == pdf_path:
continue
try:
path.unlink()
removed += 1
except OSError as exc: # pragma: no cover - a locked viewer, say
logger.warning("could not remove %s: %s", path, exc)
preview = work_dir / PREVIEW_DIR
if preview.is_dir():
shutil.rmtree(preview, ignore_errors=True)
removed += 1
return removed
|