diff --git a/CHANGES.md b/CHANGES.md index 0da5f90..5074d96 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -71,3 +71,27 @@ new logic. ## Test status `pytest tests/ -v` → **61 passed** (40 pre-existing + 21 new), no failures. + +--- + +## Addendum — report generation (closes the agentic-workflow trio) + +The JD asks for "agentic workflows for reasoning, **verification, and report +generation**." Reasoning (`agent.py`) and verification (`verification.py`) +were already present; report generation was the remaining gap. Added: + +| File | What it does | +|------|--------------| +| `report.py` | `generate_report(events, mode, title)` → `CapitalReport`. Computes the **facts in Python** (chronological timeline, per-type rollups, headline capital metrics, embedded verification) so figures are deterministic and never hallucinated, attaches an **inline `(p. N)` page citation** to every claim (pages never invented — same rule as `schema.citation_from_hit`), and renders a Markdown brief. `mode="extractive"` is free/offline; `mode="llm"` adds a Claude-written executive summary **over the computed skeleton only**, and falls back to extractive if the call fails. | +| `tests/test_report.py` | Rollup metrics, chronological ordering, page-only citation rule, embedded verification (clean + planted continuity break), Markdown rendering with citations, custom title, empty-input safety. | + +### Modified +| File | Change | +|------|--------| +| `api.py` | Added `POST /report` `{mode, title}` → structured fields + rendered Markdown. No change to existing endpoints. | +| `tests/test_api.py` | Added `/report` happy-path, bad-mode (400), and custom-title tests. | +| `requirements-api.txt` | Added `anthropic` so `/ask?mode=llm` and `/report?mode=llm` work from the documented quickstart (server still boots and serves extractive routes without a key). | +| `README.md` | Refreshed to the live numbers (F1 0.968, 73 tests), full endpoint table, and the new report stage. | + +### Test status +`pytest tests/ -q` → **73 passed** (61 prior + 12 new), no failures. diff --git a/README.md b/README.md index 278a363..9d526bc 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Tests](https://github.com/Utkal059/capscribe/actions/workflows/tests.yml/badge.svg)](https://github.com/Utkal059/capscribe/actions) -CapScribe parses dense regulatory PDF documents (DRHPs, IPO prospectuses) and extracts structured capital event data — allotments, bonus issues, rights issues, and authorised capital changes — into clean, machine-readable JSON. Built for analysts, quant researchers, and fintech pipelines that need reliable signal from unstructured filings. +CapScribe parses dense regulatory PDF documents (DRHPs, IPO prospectuses, annual reports) and extracts structured capital event data — allotments, bonus issues, rights issues, authorised-capital changes, dividends, buybacks, and warrant exercises — into clean, machine-readable JSON. Every event is traceable back to the exact page and verbatim text it came from. Built for analysts, quant researchers, and fintech pipelines that need reliable signal from unstructured filings. ## Demo @@ -26,44 +26,71 @@ Structured investor intelligence synthesised from raw DRHP events via Claude. | Bonus Issues | Date, ratio, pre/post share count | | Rights Issues | Date, ratio, price, record date | | Authorised Capital Changes | Date, from/to amount, resolution type | +| Dividends | Date, amount per share, record/payment date, total outflow | +| Share Repurchases | Date, shares bought back, remaining authority | +| Warrant Exercises | Date, warrants exercised, exercise price | ## Architecture - **Extraction** — PDF parser producing structured JSON events per `schema.py` -- **Retrieval** — ChromaDB vector store with all-MiniLM-L6-v2 embeddings -- **Agent** — LangGraph ReAct loop over `search_events` / `get_event_detail` tools -- **API** — FastAPI service (`/health`, `/stats`, `/search`, `/ask`) -- **Frontend** — Financial terminal UI (dark theme, semantic search, extractive + LLM QA) +- **Tables** — direct `pdfplumber` table extraction (`table_extractor.py`), merged-cell aware, preferred over LLM events on a fuzzy match +- **OCR** — scanned-page fallback (`ocr.py`) via tesseract, degrades gracefully when the binary is absent +- **Retrieval** — hybrid BM25 + ChromaDB vectors fused with reciprocal rank fusion (`retrieval.py`); an auto-alpha heuristic leans toward BM25 for numeric queries +- **Agent** — observable LangGraph state machine `retrieve → grade → synthesize → validate` (`agent.py`) +- **Verification** — deterministic contradiction checks: timeline / capital-continuity / bonus-arithmetic (`verification.py`) +- **Report** — source-backed capital-history brief with inline page citations (`report.py`) +- **API** — FastAPI service (`api.py`) +- **Frontend** — financial terminal UI (dark theme, semantic search, extractive + LLM QA, citation pills) + +## API + +| Endpoint | Purpose | +|---|---| +| `GET /health` | liveness; reports retrieval mode and `ocr_available` | +| `GET /stats` | event counts by type | +| `GET /events` | list events (optional `?event_type=` & `?limit=`) | +| `POST /search` | hybrid search `{query, k, alpha}` | +| `POST /ask` | agentic RAG `{question, mode}` (extractive / llm) | +| `POST /verify` | full-corpus contradiction report | +| `POST /report` | source-backed capital-history brief `{mode, title}` | +| `POST /ingest` | PDF upload → OCR fallback → table extraction | +| `POST /index` | rebuild the index from a different extracted JSON | ## Evaluation (sample DRHP) | Metric | Score | |---|---| | Precision | 1.000 | -| Recall | 0.857 | -| F1 | 0.923 | +| Recall | 0.938 | +| F1 | 0.968 | + +Per-event-type and per-extraction-method breakdowns are emitted by `evaluate.py`; retrieval quality (nDCG@5, MRR — dense vs hybrid) by `benchmark_retrieval.py`. ## Quickstart ```bash -cp .env.example .env # add ANTHROPIC_API_KEY -pip install -r requirements-api.txt +cp .env.example .env # add ANTHROPIC_API_KEY (only needed for llm modes) +pip install -r requirements.txt -r requirements-api.txt uvicorn api:app --reload # open http://localhost:8000 ``` +The index builds on startup from `fixtures/sample_events.json` using local embeddings, so search / ask / verify / report all run with **zero API spend**. Only `mode="llm"` calls Claude. + ## Tests ```bash -pytest -q # 15 passed +pip install -r requirements.txt -r requirements-api.txt -r requirements-dev.txt +pytest -q # 73 passed ``` -## Eval Harness +## Eval & Benchmarks ```bash python evaluate.py fixtures/sample_events.json fixtures/gold_events.json -``` +python benchmark_retrieval.py # nDCG@5 + MRR, dense vs hybrid +``` ## License -MIT \ No newline at end of file +MIT diff --git a/api.py b/api.py index 2bbaca2..bb55a80 100644 --- a/api.py +++ b/api.py @@ -8,6 +8,9 @@ GET /events list events (optional ?event_type= & ?limit=) POST /search semantic search {query, k} POST /ask agentic RAG {question, mode} + POST /verify full-corpus contradiction report + POST /report source-backed capital-history brief {mode, title} + POST /ingest PDF upload -> OCR fallback + table extraction POST /index rebuild the index from a different extracted JSON The index is built once on startup from settings.events_path using local @@ -31,6 +34,7 @@ from agent import CapScribeAgent from config import settings from ocr import ocr_available, process_document +from report import generate_report from retrieval import EventStore, HybridRetriever, load_events from table_extractor import TableExtractor, merge_with_dedup from verification import events_from_store, verify_report @@ -77,6 +81,11 @@ class IndexRequest(BaseModel): events_path: str +class ReportRequest(BaseModel): + mode: str = "extractive" # "extractive" (free) | "llm" (Claude summary) + title: str | None = None + + def _build(events_path: str) -> int: store = EventStore() # default local embeddings, persistent n = store.index_events(load_events(events_path)) @@ -161,6 +170,20 @@ def verify() -> dict: return verify_report(events).model_dump() +@app.post("/report") +def report(req: ReportRequest) -> dict: + """Generate a source-backed capital-history brief over the indexed filing. + + ``mode="extractive"`` (default) is free and deterministic; ``mode="llm"`` + adds a Claude-written executive summary over the same computed facts. + Returns structured fields plus a rendered Markdown brief. + """ + if req.mode not in ("extractive", "llm"): + raise HTTPException(400, "mode must be 'extractive' or 'llm'") + events = events_from_store(_retriever()) + return generate_report(events, mode=req.mode, title=req.title).model_dump() + + @app.post("/ingest") async def ingest(file: UploadFile = File(...)) -> dict: """Ingest a PDF: OCR-fallback text extraction → table extraction → summary. diff --git a/report.py b/report.py new file mode 100644 index 0000000..8894fd8 --- /dev/null +++ b/report.py @@ -0,0 +1,288 @@ +"""Source-backed capital-history report generation. + +The JD's agentic workflow ends in *report generation*: turning the verified, +cited event set into an analyst-ready brief. This module is the final stage +after ``retrieval -> reasoning -> verification``. + +Design (mirrors the rest of the system — "simple, observable systems over +clever but fragile ones"): + + - The **facts are computed in Python** — a chronological timeline, + per-type rollups, headline capital metrics, and the verification + findings — so the figures are deterministic and never hallucinated. + - Every claim carries an **inline page citation** ``(p. N)`` built only + from events that genuinely record a page + snippet; pages are never + invented (same rule as ``schema.citation_from_hit``). + - ``mode="extractive"`` (default) is free and offline. ``mode="llm"`` + asks Claude to write a short executive summary **from the computed + skeleton only**, so the prose cannot introduce a number the events + don't support. + +The public entry point :func:`generate_report` takes a list of event dicts +(as stored in the vector collection) and returns a :class:`CapitalReport` +with both structured fields and a rendered Markdown brief. +""" +from __future__ import annotations + +import logging +from typing import Any, Literal, Optional + +from pydantic import BaseModel, Field + +from config import settings +from schema import Citation +from verification import verify_report + +logger = logging.getLogger("capscribe.report") + + +# ── models ──────────────────────────────────────────────────────────────────── + +class TimelineEntry(BaseModel): + date: str | None = None + event_type: str + headline: str + page_number: int | None = None + source_snippet: str | None = None + + +class CapitalReport(BaseModel): + """An analyst-ready capital-history brief over an extracted filing.""" + + title: str + mode: Literal["extractive", "llm"] = "extractive" + event_count: int = 0 + by_type: dict[str, int] = Field(default_factory=dict) + date_range: list[str | None] = Field(default_factory=lambda: [None, None]) + metrics: dict[str, Any] = Field(default_factory=dict) + timeline: list[TimelineEntry] = Field(default_factory=list) + verification: dict[str, Any] = Field(default_factory=dict) + citations: list[Citation] = Field(default_factory=list) + narrative: str = "" # short prose summary (llm mode) or "" (extractive) + markdown: str = "" # full rendered brief + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _fmt_inr(amount: float | int | None) -> str: + """Render a rupee figure with crore/lakh scale for readability.""" + if amount is None: + return "—" + a = float(amount) + if a >= 1e7: + return f"Rs. {a / 1e7:.2f} Cr" + if a >= 1e5: + return f"Rs. {a / 1e5:.2f} L" + return f"Rs. {a:,.0f}" + + +def _cite(ev: dict) -> str: + """Inline ``(p. N)`` marker when a page is known, else "".""" + page = ev.get("page_number") + return f" (p. {page})" if page is not None else "" + + +def _headline(ev: dict) -> str: + """One-line human summary of an event for the timeline.""" + et = ev.get("event_type", "event") + if et == "allotment": + who = ev.get("allottee_category") + bit = f" to {who}" if who else "" + price = ev.get("issue_price") + at = f" at Rs. {price}" if price else "" + return f"Allotted {ev.get('shares', '?'):,} shares{bit}{at}".replace("?,", "?") + if et == "bonus_issue": + return f"Bonus issue {ev.get('ratio', '?')}" + if et == "rights_issue": + return f"Rights issue {ev.get('ratio', '?')} at Rs. {ev.get('price', '?')}" + if et == "authorised_capital_change": + return (f"Authorised capital raised {_fmt_inr(ev.get('old_capital'))} " + f"→ {_fmt_inr(ev.get('new_capital'))}") + if et == "dividend_declaration": + return f"Dividend Rs. {ev.get('amount_per_share', '?')}/share" + if et == "share_repurchase": + n = ev.get("shares_bought_back") + return f"Buyback of {n:,} shares" if isinstance(n, int) else "Share repurchase" + if et == "warrant_exercise": + n = ev.get("warrants_exercised") + return f"{n:,} warrants exercised" if isinstance(n, int) else "Warrant exercise" + return et.replace("_", " ") + + +def _safe_int_sum(events: list[dict], etype: str, field: str) -> int: + return sum(int(e[field]) for e in events + if e.get("event_type") == etype and isinstance(e.get(field), (int, float))) + + +def _metrics(events: list[dict]) -> dict[str, Any]: + """Headline capital metrics rolled up across the event set.""" + acc = sorted( + (e for e in events if e.get("event_type") == "authorised_capital_change"), + key=lambda e: (e.get("date") or ""), + ) + latest_capital = acc[-1].get("new_capital") if acc else None + return { + "total_shares_allotted": _safe_int_sum(events, "allotment", "shares"), + "allotment_events": sum(1 for e in events if e.get("event_type") == "allotment"), + "bonus_issues": sum(1 for e in events if e.get("event_type") == "bonus_issue"), + "rights_issues": sum(1 for e in events if e.get("event_type") == "rights_issue"), + "latest_authorised_capital": latest_capital, + "latest_authorised_capital_fmt": _fmt_inr(latest_capital), + "total_dividend_outflow": _safe_int_sum(events, "dividend_declaration", "total_outflow"), + "total_shares_bought_back": _safe_int_sum(events, "share_repurchase", "shares_bought_back"), + "total_warrants_exercised": _safe_int_sum(events, "warrant_exercise", "warrants_exercised"), + } + + +def _timeline(events: list[dict]) -> list[TimelineEntry]: + dated = sorted(events, key=lambda e: (e.get("date") or "9999-99-99")) + return [ + TimelineEntry( + date=e.get("date"), + event_type=e.get("event_type", "event"), + headline=_headline(e), + page_number=e.get("page_number"), + source_snippet=e.get("source_snippet"), + ) + for e in dated + ] + + +def _citations(events: list[dict]) -> list[Citation]: + """Page-backed citations — only for events with a real page + snippet.""" + out: list[Citation] = [] + for i, e in enumerate(events): + page, snippet = e.get("page_number"), e.get("source_snippet") + if page is None or snippet is None: + continue + section = (e.get("source_provenance") or {}).get("section") + out.append(Citation( + event_id=str(e.get("event_id") or f"{e.get('event_type', 'event')}@p{page}"), + page_number=int(page), + source_snippet=str(snippet), + section_heading=section, + )) + return out + + +def _render_markdown(report: CapitalReport) -> str: + m = report.metrics + lines = [ + f"# {report.title}", + "", + f"*{report.event_count} capital events" + + (f" spanning {report.date_range[0]} → {report.date_range[1]}*" + if report.date_range[0] else "*"), + "", + "## Snapshot", + "", + f"- Latest authorised capital: **{m.get('latest_authorised_capital_fmt', '—')}**", + f"- Total shares allotted: **{m.get('total_shares_allotted', 0):,}** " + f"across {m.get('allotment_events', 0)} allotments", + f"- Bonus issues: **{m.get('bonus_issues', 0)}** · " + f"Rights issues: **{m.get('rights_issues', 0)}**", + f"- Total dividend outflow: **{_fmt_inr(m.get('total_dividend_outflow') or None)}**", + f"- Shares bought back: **{m.get('total_shares_bought_back', 0):,}** · " + f"Warrants exercised: **{m.get('total_warrants_exercised', 0):,}**", + "", + "## Capital timeline", + "", + ] + for entry in report.timeline: + page = f" (p. {entry.page_number})" if entry.page_number is not None else "" + date = entry.date or "undated" + lines.append(f"- **{date}** — {entry.headline}{page}") + lines += ["", "## Verification", ""] + v = report.verification + if v.get("consistent", True): + lines.append(f"- ✅ No contradictions across {v.get('checked', 0)} events " + "(timeline / capital-continuity / bonus-arithmetic).") + else: + lines.append(f"- ⚠️ **{len(v.get('issues', []))} contradiction(s)** found " + f"across {v.get('checked', 0)} events:") + for issue in v.get("issues", []): + lines.append(f" - [{issue.get('check_type')}] {issue.get('description')}") + if report.narrative: + lines = [f"# {report.title}", "", "## Executive summary", "", + report.narrative, ""] + lines[3:] + lines += ["", f"*{len(report.citations)} of {report.event_count} events are " + "page-cited back to the source filing.*"] + return "\n".join(lines) + + +# ── LLM narrative (optional) ────────────────────────────────────────────────── + +def _llm_narrative(report: CapitalReport) -> str: + """A short executive paragraph written from the computed skeleton only.""" + from anthropic import Anthropic + + facts = { + "metrics": report.metrics, + "timeline": [t.model_dump() for t in report.timeline], + "verification": report.verification, + } + client = Anthropic(api_key=settings.anthropic_api_key or None) + msg = client.messages.create( + model=settings.answer_model, + max_tokens=350, + system=( + "You are a capital-markets analyst. Write a 3-4 sentence executive " + "summary of a company's capital history using ONLY the JSON facts " + "provided. Never introduce a figure that is not in the facts. Be " + "precise and neutral; reference dates where relevant." + ), + messages=[{"role": "user", "content": str(facts)}], + ) + return "".join(b.text for b in msg.content if getattr(b, "type", "") == "text").strip() + + +# ── public API ──────────────────────────────────────────────────────────────── + +def generate_report( + events: list[dict], + mode: Literal["extractive", "llm"] = "extractive", + title: Optional[str] = None, +) -> CapitalReport: + """Build a source-backed capital-history report over ``events``. + + Args: + events: extracted event dicts (as stored in the vector collection). + mode: ``extractive`` (free, deterministic) or ``llm`` (adds a + Claude-written executive summary over the same computed facts). + title: optional report title. + + Returns: + A :class:`CapitalReport` with structured fields, page citations, the + verification result, and a rendered Markdown brief. + """ + dates = sorted(e["date"] for e in events if e.get("date")) + report = CapitalReport( + title=title or "Capital History Report", + mode=mode, + event_count=len(events), + by_type=_count_by_type(events), + date_range=[dates[0] if dates else None, dates[-1] if dates else None], + metrics=_metrics(events), + timeline=_timeline(events), + verification=verify_report(events).model_dump(), + citations=_citations(events), + ) + if mode == "llm" and events: + try: + report.narrative = _llm_narrative(report) + except Exception as exc: # never fail the report on an LLM hiccup + logger.warning("llm narrative failed, falling back to extractive: %s", exc) + report.mode = "extractive" + report.markdown = _render_markdown(report) + logger.info("report: %d events, %d citations, consistent=%s", + report.event_count, len(report.citations), + report.verification.get("consistent")) + return report + + +def _count_by_type(events: list[dict]) -> dict[str, int]: + out: dict[str, int] = {} + for e in events: + et = e.get("event_type", "unknown") + out[et] = out.get(et, 0) + 1 + return out diff --git a/requirements-api.txt b/requirements-api.txt index 9b509bf..5671ca4 100644 --- a/requirements-api.txt +++ b/requirements-api.txt @@ -16,3 +16,7 @@ pytesseract==0.3.13 # requires the system `tesseract` binary for OCR # PDF upload handling for POST /ingest python-multipart==0.0.29 + +# Claude SDK — only used by POST /ask?mode=llm and POST /report?mode=llm. +# The server boots and serves extractive search/ask/report without a key. +anthropic>=0.40.0 diff --git a/tests/test_api.py b/tests/test_api.py index 1f28321..60a40cd 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -67,6 +67,28 @@ def test_verify_endpoint(store): assert "by_check" in body +def test_report_endpoint(store): + r = _client(store).post("/report", json={"mode": "extractive"}) + assert r.status_code == 200 + body = r.json() + assert body["event_count"] == store.count() + assert body["mode"] == "extractive" + assert body["markdown"].startswith("# Capital History Report") + assert "verification" in body + assert isinstance(body["citations"], list) + + +def test_report_rejects_bad_mode(store): + r = _client(store).post("/report", json={"mode": "bogus"}) + assert r.status_code == 400 + + +def test_report_custom_title(store): + r = _client(store).post("/report", json={"title": "Sample Filing — Brief"}) + assert r.status_code == 200 + assert r.json()["title"] == "Sample Filing — Brief" + + def test_ingest_rejects_non_pdf(store): r = _client(store).post( "/ingest", files={"file": ("notes.txt", b"not a pdf", "text/plain")} diff --git a/tests/test_report.py b/tests/test_report.py new file mode 100644 index 0000000..81d342e --- /dev/null +++ b/tests/test_report.py @@ -0,0 +1,108 @@ +"""Capital-history report tests. + +The report skeleton is computed deterministically (no API, no PDF, no store), +so these run fully offline. They cover the rollup metrics, chronological +timeline, page-only citation rule, embedded verification, Markdown rendering, +and the empty-input edge case. +""" +from __future__ import annotations + +from report import generate_report + +EVENTS = [ + {"event_type": "allotment", "date": "2017-02-03", "shares": 10000, + "issue_price": 10, "allottee_category": "promoters", + "page_number": 72, "source_snippet": "allotted 10,000 Equity Shares ...", + "event_id": "al1"}, + {"event_type": "allotment", "date": "2019-06-21", "shares": 50000, + "issue_price": 154.20, "allottee_category": "investors", + "page_number": 73, "source_snippet": "preferential allotment at Rs. 154.20 ...", + "event_id": "al2"}, + {"event_type": "bonus_issue", "date": "2021-08-01", "ratio": "5:1", + "pre_issue_capital": 100000000, "post_issue_capital": 600000000, + "page_number": 75, "source_snippet": "bonus issue in the ratio 5:1 ...", + "event_id": "b1"}, + {"event_type": "authorised_capital_change", "date": "2023-01-10", + "old_capital": 600000000, "new_capital": 1500000000, + "resolution_type": "special_resolution", + "page_number": 78, "source_snippet": "authorised share capital increased ...", + "event_id": "acc1"}, + {"event_type": "dividend_declaration", "date": "2022-07-15", + "amount_per_share": 2.5, "total_outflow": 150000000, + "page_number": 92, "source_snippet": "final dividend of Rs. 2.50 ...", + "event_id": "d1"}, +] + + +def test_report_basic_shape(): + r = generate_report(EVENTS) + assert r.event_count == 5 + assert r.mode == "extractive" + assert r.by_type["allotment"] == 2 + assert r.date_range == ["2017-02-03", "2023-01-10"] + + +def test_metrics_rollups(): + m = generate_report(EVENTS).metrics + assert m["total_shares_allotted"] == 60000 # 10000 + 50000 + assert m["allotment_events"] == 2 + assert m["bonus_issues"] == 1 + assert m["latest_authorised_capital"] == 1500000000 + assert m["total_dividend_outflow"] == 150000000 + + +def test_timeline_is_chronological(): + timeline = generate_report(EVENTS).timeline + dates = [t.date for t in timeline] + assert dates == sorted(dates) + assert timeline[0].date == "2017-02-03" + + +def test_citations_only_when_page_known(): + events = EVENTS + [{"event_type": "allotment", "date": "2024-01-01", + "shares": 1, "event_id": "nopage"}] # no page/snippet + r = generate_report(events) + # every cited event has a page; the page-less one is excluded + assert all(c.page_number for c in r.citations) + assert len(r.citations) == 5 + assert "nopage" not in {c.event_id for c in r.citations} + + +def test_verification_embedded(): + r = generate_report(EVENTS) + assert r.verification["consistent"] is True + assert r.verification["checked"] == 5 + + +def test_inconsistency_surfaces_in_report(): + broken = EVENTS + [ + {"event_type": "authorised_capital_change", "date": "2024-01-01", + "old_capital": 999, "new_capital": 2000000000, "event_id": "acc2"}, + ] # 1,500,000,000 (2023) != 999 (2024 old) -> continuity break + r = generate_report(broken) + assert r.verification["consistent"] is False + assert "capital_continuity" in r.verification["by_check"] + + +def test_markdown_rendered_with_citations(): + md = generate_report(EVENTS).markdown + assert md.startswith("# Capital History Report") + assert "## Capital timeline" in md + assert "## Verification" in md + assert "(p. 72)" in md # inline page citation present + assert "Rs. 150.00 Cr" in md # latest authorised capital formatted + + +def test_custom_title(): + r = generate_report(EVENTS, title="Ola Electric — Capital Brief") + assert r.title == "Ola Electric — Capital Brief" + assert r.markdown.startswith("# Ola Electric — Capital Brief") + + +def test_empty_events_is_safe(): + r = generate_report([]) + assert r.event_count == 0 + assert r.date_range == [None, None] + assert r.timeline == [] + assert r.verification["consistent"] is True + assert r.markdown.startswith("# Capital History Report")