From e2ce8153dcdb87235e91f5ae05579532f880636e Mon Sep 17 00:00:00 2001
From: Ayushmangela
+
A public, browsable dashboard that shows exactly what changed in a company's SEC risk-factor disclosures between consecutive filings — a GitHub pull-request diff, applied to prose.
@@ -32,7 +33,12 @@ strip gives an at-a-glance sentence count of what changed before you read a word itself, a section-jump sidebar lets you skip straight to a specific risk topic instead of scrolling a 40-paragraph document, and an analyst-metrics panel surfaces three deterministic, citation-backed disclosure-analysis figures (textual similarity, Fog readability, section length) alongside the -qualitative diff. +qualitative diff, plotted as a trend across every filing the company has, not just the two periods +currently selected. + +The pre-seeded universe (~38 well-known tickers) isn't a hard limit: searching a ticker that isn't +indexed yet offers to import it live, straight from SEC EDGAR, using the exact same extraction/diff +pipeline as the offline batch job. This is a standalone project built to demonstrate four things: **SEC EDGAR filing ingestion and section-level text extraction** (locating a specific, inconsistently-formatted legal section @@ -51,6 +57,7 @@ handling** for the one optional AI feature in the app. - [Why this exists](#why-this-exists) - [Architecture](#architecture) - [Installation / running locally](#installation--running-locally) +- [Testing](#testing) - [Quickstart](#quickstart) - [How the diffing works](#how-the-diffing-works) - [Data handling & privacy](#data-handling--privacy) @@ -109,8 +116,7 @@ passthrough endpoint or proxy — the request goes straight from your browser to ## Installation / running locally -Requires Python 3.9+. No Docker, no build step for the frontend, no separate database server, no -automated test suite (by design — this project puts that time into UI polish instead). +Requires Python 3.9+. No Docker, no build step for the frontend, no separate database server. ```bash git clone https://github.com/divyaanshkumar24/SEC-Filing-Risk-Factor-Diff-Tracker.git @@ -141,25 +147,42 @@ No restart, no config file — the key lives only in that browser tab for that s database from scratch, re-fetching current filings. This project intentionally does not auto-refresh on a schedule — re-run it whenever you want an updated snapshot. +## Testing + +```bash +pytest backend/tests/ -v +``` + +Covers the Item 1A section-isolation heuristic against synthetic filing fixtures +([`test_extract.py`](backend/tests/test_extract.py)), the diff/analyst-metrics functions against +hand-computed inputs ([`test_diffing.py`](backend/tests/test_diffing.py)), and every route in +`main.py` via FastAPI's `TestClient` against a throwaway seeded SQLite DB +([`test_api.py`](backend/tests/test_api.py)) — nothing touches the real `tracker.db`. Runs in CI on +every push/PR ([`.github/workflows/tests.yml`](.github/workflows/tests.yml)). + ## Quickstart 1. Open the homepage — a "biggest recent changes" strip highlights the companies with the largest sentence-level change in their latest comparison, below which is a searchable, sector-filterable, sortable (A–Z or biggest change) grid of the whole company universe. Each card shows ticker, name, sector, and a "+N / −M" badge for its most recent filing-over-filing change. -2. Click a company card to open its detail page. -3. Use the two period dropdowns (or click a dot on the timeline) to pick which two filings to +2. Searching a ticker or name with no local match offers to import it live from SEC EDGAR — the + same fetch/extract/diff pipeline as the offline ingestion, just scoped to one company and run + on request. +3. Click a company card to open its detail page. Two trend charts show textual similarity and Fog + readability across every filing pair the company has, not just the pair currently selected. +4. Use the two period dropdowns (or click a dot on the timeline) to pick which two filings to compare — any two, not just consecutive ones. -4. Read the diff: added paragraphs highlighted in blue with a `+`, removed paragraphs in amber +5. Read the diff: added paragraphs highlighted in blue with a `+`, removed paragraphs in amber with a `−` and a strikethrough, unchanged paragraphs in neutral grey. The summary strip above it gives the sentence-level added/removed counts and links to both original filings on SEC EDGAR. -5. Use the **Jump to section** sidebar to skip directly to a specific risk topic (e.g. "Macroeconomic +6. Use the **Jump to section** sidebar to skip directly to a specific risk topic (e.g. "Macroeconomic and Industry Risks") instead of scrolling the whole document — it highlights your current section as you scroll, and the **Analyst metrics** panel above it gives you textual similarity, Fog readability, and section length for this comparison (see [How the diffing works](#how-the-diffing-works)). -6. If you've entered an API key, a plain-English AI summary of the change appears in its own panel +7. If you've entered an API key, a plain-English AI summary of the change appears in its own panel below the summary strip. -7. Toggle light/dark theme from the circular button in the top-right at any time. +8. Toggle light/dark theme from the circular button in the top-right at any time. ## How the diffing works @@ -258,7 +281,9 @@ similarity score or a rising Fog index is a prompt to go read the diff, not a co | `GET` | `/api/health` | Liveness check + whether the database has been populated | | `GET` | `/api/meta` | Dataset "data as of" date and ingested company count | | `GET` | `/api/companies` | List every company with >=2 ingested filings, with a latest-change summary badge | -| `GET` | `/api/companies/{ticker}` | Company detail: all ingested filing periods with dates and source URLs | +| `GET` | `/api/companies/search?q=` | Search locally-indexed companies plus SEC's full ticker map, so a match that isn't indexed yet can be offered for import | +| `POST` | `/api/companies/{ticker}/import` | Fetch, extract, and diff every available 10-K for a ticker outside the pre-seeded universe | +| `GET` | `/api/companies/{ticker}` | Company detail: all ingested filing periods with dates/source URLs, plus a `trend` array (similarity/Fog/word-count per consecutive filing pair) for the trend charts | | `GET` | `/api/companies/{ticker}/diff?from_id=&to_id=` | Precomputed paragraph-level diff (each chunk flagged `heading: true/false` for the section nav) + summary + `analyst_metrics` between two periods | This backend never accepts or forwards an Anthropic API key — see @@ -284,14 +309,19 @@ sqlite · open-source** JSON API. No ORM — plain `sqlite3` with hand-written, parameterized queries. - **Ingestion:** a standalone script ([`backend/app/ingest.py`](backend/app/ingest.py)) that pulls SEC EDGAR's submissions API and each filing's primary HTML document, run offline — never in the - live request path. + live request path. [`backend/app/acquisition.py`](backend/app/acquisition.py) reuses the same + fetch/extract/diff functions to scope that pipeline to a single on-demand ticker, callable from + the live API. - **Extraction & diffing:** dependency-light Python ([`backend/app/extract.py`](backend/app/extract.py), [`backend/app/diffing.py`](backend/app/diffing.py)) using BeautifulSoup for HTML-to-text and the standard-library `difflib` for comparison — no NLP model involved. - **Frontend:** vanilla HTML/CSS/JS with ES modules, no build step, no framework — a hash-based router between the browse grid and company detail views, with a serif reading typeface and a - colorblind-conscious diff palette. + colorblind-conscious diff palette. The trend charts ([`frontend/js/chart.js`](frontend/js/chart.js)) + are hand-rolled themed SVG, not an external charting library — there are never more than a + handful of data points, and inline SVG lets the marks follow the app's own CSS custom properties + (and light/dark theme) for free. - **Database:** SQLite — a single file, no server process, trivially inspectable with any SQLite client. diff --git a/backend/app/acquisition.py b/backend/app/acquisition.py new file mode 100644 index 0000000..ca37f2a --- /dev/null +++ b/backend/app/acquisition.py @@ -0,0 +1,76 @@ +"""On-demand import of a single company outside the pre-seeded universe. + +Reuses the exact same fetch/extract/diff pipeline as the offline batch job +in ingest.py -- this just scopes it to one ticker and is callable from a +live API request. No separate logic, no embeddings, nothing invented. +""" + +from .companies import SECTORS +from .ingest import ( + TICKER_MAP_URL, + _get, + compute_diffs_for_ticker, + ingest_ticker, +) + +_ticker_rows_cache = None + + +def _ticker_rows(): + """SEC's full ticker->CIK->title map, cached for the life of the process + (it changes rarely and this endpoint may be hit repeatedly per search).""" + global _ticker_rows_cache + if _ticker_rows_cache is None: + _ticker_rows_cache = list(_get(TICKER_MAP_URL).json().values()) + return _ticker_rows_cache + + +def resolve_ticker(ticker: str): + """Return (cik10, display_name) for a ticker via SEC's authoritative + map, or (None, None) if it's not a filer SEC knows about.""" + ticker = ticker.upper() + for row in _ticker_rows(): + if row["ticker"].upper() == ticker: + return str(row["cik_str"]).zfill(10), row["title"] + return None, None + + +def search_tickers(query: str, limit: int = 8): + query = query.strip().upper() + if not query: + return [] + results = [] + for row in _ticker_rows(): + ticker = row["ticker"].upper() + title = row["title"] + if query in ticker or query.lower() in title.lower(): + results.append({"ticker": ticker, "name": title}) + if len(results) >= limit: + break + return results + + +def import_company(conn, ticker: str) -> int: + """Fetch, extract, and diff every available 10-K for `ticker`, storing + the company row if it's new. Returns the number of usable filings + stored. Raises ValueError on a ticker SEC doesn't recognize or one with + fewer than 2 usable Item 1A sections (too few to diff).""" + ticker = ticker.upper() + cik10, name = resolve_ticker(ticker) + if not cik10: + raise ValueError(f"'{ticker}' was not found in SEC's ticker map") + + conn.execute( + "INSERT OR IGNORE INTO companies (ticker, name, cik, sector) VALUES (?, ?, ?, ?)", + (ticker, name, cik10, SECTORS.get(ticker, "Other")), + ) + conn.commit() + + stored = ingest_ticker(conn, ticker, cik10) + if stored < 2: + raise ValueError( + f"Only {stored} usable 10-K filing(s) found for '{ticker}' -- " + "need at least 2 to compute a diff" + ) + compute_diffs_for_ticker(conn, ticker) + return stored diff --git a/backend/app/main.py b/backend/app/main.py index b7aa425..14fe093 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,7 +14,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse -from . import db +from . import acquisition, db FRONTEND_DIR = Path(__file__).resolve().parent.parent.parent / "frontend" @@ -23,7 +23,7 @@ app.add_middleware( CORSMiddleware, allow_origins=["*"], - allow_methods=["GET"], + allow_methods=["GET", "POST"], allow_headers=["*"], ) @@ -83,6 +83,54 @@ def list_companies(): return {"companies": results} +@app.get("/api/companies/search") +def search_companies(q: str = ""): + """Search both the locally-indexed universe and SEC's full ticker map, + so the frontend can offer to import a match that isn't indexed yet.""" + q_norm = q.strip().upper() + if not q_norm: + return {"results": []} + + conn = db.get_conn() + local_rows = conn.execute( + """SELECT c.ticker, c.name, c.sector, COUNT(f.id) as filing_count + FROM companies c JOIN filings f ON f.ticker = c.ticker + WHERE UPPER(c.ticker) LIKE ? OR UPPER(c.name) LIKE ? + GROUP BY c.ticker HAVING COUNT(f.id) >= 2 + ORDER BY c.ticker""", + (f"%{q_norm}%", f"%{q_norm}%"), + ).fetchall() + conn.close() + + local_tickers = {r["ticker"] for r in local_rows} + results = [ + {"ticker": r["ticker"], "name": r["name"], "sector": r["sector"], "is_indexed": True} + for r in local_rows + ] + + try: + for cand in acquisition.search_tickers(q_norm, limit=8): + if cand["ticker"] not in local_tickers: + results.append({"ticker": cand["ticker"], "name": cand["name"], "sector": None, "is_indexed": False}) + except Exception: + pass # SEC lookup is best-effort; local results still return + + return {"results": results[:12]} + + +@app.post("/api/companies/{ticker}/import") +def import_company(ticker: str): + ticker = ticker.upper() + conn = db.get_conn() + try: + stored = acquisition.import_company(conn, ticker) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + finally: + conn.close() + return {"ticker": ticker, "filings_stored": stored} + + @app.get("/api/companies/{ticker}") def company_detail(ticker: str): ticker = ticker.upper() @@ -97,11 +145,30 @@ def company_detail(ticker: str): FROM filings WHERE ticker = ? ORDER BY filing_date ASC""", (ticker,), ).fetchall() - conn.close() if len(filings) < 2: + conn.close() raise HTTPException(status_code=404, detail="Not enough filings ingested for this company yet") + # Consecutive-pair diffs only, in chronological order, for the trend chart -- + # every point here is a real precomputed analyst_metrics row, nothing derived. + trend = [] + for i in range(len(filings) - 1): + from_f, to_f = filings[i], filings[i + 1] + diff_row = conn.execute( + """SELECT similarity_score, fog_index_to, word_count_to + FROM diffs WHERE from_filing_id = ? AND to_filing_id = ?""", + (from_f["id"], to_f["id"]), + ).fetchone() + if diff_row: + trend.append({ + "label": (to_f["period_of_report"] or to_f["filing_date"])[:4], + "similarity_score": diff_row["similarity_score"], + "fog_index": diff_row["fog_index_to"], + "word_count": diff_row["word_count_to"], + }) + conn.close() + return { "ticker": company["ticker"], "name": company["name"], @@ -119,6 +186,7 @@ def company_detail(ticker: str): } for f in filings ], + "trend": trend, } diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..3732d5b --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,79 @@ +"""Shared fixtures: every test runs against a throwaway SQLite DB, never the +real tracker.db, via monkeypatching db.DB_PATH before anything opens a +connection. +""" + +import json + +import pytest + +from backend.app import db +from backend.app.diffing import compute_analyst_metrics, diff_paragraphs + + +@pytest.fixture +def test_db(tmp_path, monkeypatch): + db_path = tmp_path / "test_tracker.db" + monkeypatch.setattr(db, "DB_PATH", db_path) + conn = db.init_db(reset=True) + conn.close() + yield db_path + + +def seed_company_with_diff(conn, ticker="ACME", sector="Technology"): + """Insert one company with two filings and their precomputed diff -- + the minimal shape every /api/companies/* endpoint expects.""" + conn.execute( + "INSERT INTO companies (ticker, name, cik, sector) VALUES (?, ?, ?, ?)", + (ticker, f"{ticker} Corp", "0000000001", sector), + ) + + paras_a = [ + "Risk Factors", + "Our business depends on continued demand for our products.", + "We face significant competition in our core markets.", + ] + paras_b = [ + "Risk Factors", + "Our business depends on continued demand for our products.", + "We face significant competition in our core markets, including from new entrants.", + "Cybersecurity incidents could disrupt our operations.", + ] + text_a = "\n\n".join(paras_a) + text_b = "\n\n".join(paras_b) + + from_id = conn.execute( + """INSERT INTO filings + (ticker, form_type, fiscal_year, period_of_report, filing_date, + accession_no, source_url, risk_factor_text, char_count, paragraph_count) + VALUES (?, '10-K', '2022', '2022-12-31', '2023-01-15', 'acc-1', 'https://example.com/1', ?, ?, ?)""", + (ticker, text_a, len(text_a), len(paras_a)), + ).lastrowid + to_id = conn.execute( + """INSERT INTO filings + (ticker, form_type, fiscal_year, period_of_report, filing_date, + accession_no, source_url, risk_factor_text, char_count, paragraph_count) + VALUES (?, '10-K', '2023', '2023-12-31', '2024-01-15', 'acc-2', 'https://example.com/2', ?, ?, ?)""", + (ticker, text_b, len(text_b), len(paras_b)), + ).lastrowid + + chunks, summary = diff_paragraphs(paras_a, paras_b) + metrics = compute_analyst_metrics(text_a, text_b) + conn.execute( + """INSERT INTO diffs + (ticker, from_filing_id, to_filing_id, added_chunks, removed_chunks, + unchanged_chunks, added_sentences, removed_sentences, chunks_json, + similarity_score, fog_index_from, fog_index_to, + word_count_from, word_count_to, word_count_change_pct) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + ticker, from_id, to_id, + summary["added_chunks"], summary["removed_chunks"], summary["unchanged_chunks"], + summary["added_sentences"], summary["removed_sentences"], + json.dumps(chunks), + metrics["similarity_score"], metrics["fog_index_from"], metrics["fog_index_to"], + metrics["word_count_from"], metrics["word_count_to"], metrics["word_count_change_pct"], + ), + ) + conn.commit() + return from_id, to_id diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..4c7dc0c --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,127 @@ +"""Smoke tests over every route in main.py, via FastAPI's TestClient against +a throwaway seeded DB. This is the layer that would have caught a broken +import (like the old `from llm_local import ...` bug) or a missing-column +regression immediately -- every route must actually return, not just parse. +""" + +from fastapi.testclient import TestClient + +from backend.app.main import app +from backend.tests.conftest import seed_company_with_diff +from backend.app import db + + +def _client(): + return TestClient(app) + + +def test_health(test_db): + client = _client() + resp = client.get("/api/health") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ok" + assert body["database_populated"] is True + + +def test_meta_with_no_data(test_db): + client = _client() + resp = client.get("/api/meta") + assert resp.status_code == 200 + assert resp.json()["company_count"] == 0 + + +def test_list_companies_and_detail_and_diff(test_db): + conn = db.get_conn() + from_id, to_id = seed_company_with_diff(conn) + conn.close() + + client = _client() + + resp = client.get("/api/companies") + assert resp.status_code == 200 + companies = resp.json()["companies"] + assert len(companies) == 1 + assert companies[0]["ticker"] == "ACME" + assert companies[0]["latest_change"]["added"] >= 1 + + resp = client.get("/api/companies/ACME") + assert resp.status_code == 200 + detail = resp.json() + assert detail["ticker"] == "ACME" + assert len(detail["periods"]) == 2 + assert len(detail["trend"]) == 1 + assert detail["trend"][0]["similarity_score"] is not None + + resp = client.get(f"/api/companies/ACME/diff?from_id={from_id}&to_id={to_id}") + assert resp.status_code == 200 + diff = resp.json() + assert diff["summary"]["added_chunks"] >= 1 + assert "analyst_metrics" in diff + assert "similarity_score" in diff["analyst_metrics"] + assert isinstance(diff["chunks"], list) + # Guard against the fabricated-data regression this whole cleanup was + # about: no score/verdict/AI-confidence field should ever appear here. + assert "risk_heatmap" not in diff + assert "top_important_changes" not in diff + assert "ai_confidence" not in diff + + +def test_company_detail_unknown_ticker_is_404(test_db): + resp = _client().get("/api/companies/NOPE") + assert resp.status_code == 404 + + +def test_company_detail_with_fewer_than_two_filings_is_404(test_db): + conn = db.get_conn() + conn.execute( + "INSERT INTO companies (ticker, name, cik, sector) VALUES ('SOLO', 'Solo Corp', '1', 'Technology')" + ) + conn.commit() + conn.close() + resp = _client().get("/api/companies/SOLO") + assert resp.status_code == 404 + + +def test_diff_unknown_period_pair_is_404(test_db): + conn = db.get_conn() + seed_company_with_diff(conn) + conn.close() + resp = _client().get("/api/companies/ACME/diff?from_id=9999&to_id=9998") + assert resp.status_code == 404 + + +def test_search_empty_query_returns_empty(test_db): + resp = _client().get("/api/companies/search?q=") + assert resp.status_code == 200 + assert resp.json()["results"] == [] + + +def test_search_matches_local_company(test_db, monkeypatch): + from backend.app import acquisition + + # Local match is what's under test here -- stub out the SEC lookup so + # this test doesn't depend on network access. + monkeypatch.setattr(acquisition, "search_tickers", lambda q, limit=8: []) + + conn = db.get_conn() + seed_company_with_diff(conn) + conn.close() + resp = _client().get("/api/companies/search?q=ACM") + assert resp.status_code == 200 + results = resp.json()["results"] + assert any(r["ticker"] == "ACME" and r["is_indexed"] for r in results) + + +def test_import_unknown_ticker_returns_422(test_db, monkeypatch): + from backend.app import acquisition + + monkeypatch.setattr(acquisition, "resolve_ticker", lambda t: (None, None)) + resp = _client().post("/api/companies/BOGUS123/import") + assert resp.status_code == 422 + + +def test_index_serves_frontend(test_db): + resp = _client().get("/") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] diff --git a/backend/tests/test_diffing.py b/backend/tests/test_diffing.py new file mode 100644 index 0000000..238f2ff --- /dev/null +++ b/backend/tests/test_diffing.py @@ -0,0 +1,80 @@ +import math + +from backend.app.diffing import ( + compute_analyst_metrics, + cosine_similarity_score, + diff_paragraphs, + fog_index, +) + + +def test_diff_paragraphs_classifies_unchanged_added_removed(): + a = ["Alpha paragraph.", "Beta paragraph.", "Gamma paragraph."] + b = ["Alpha paragraph.", "Gamma paragraph.", "Delta paragraph."] + chunks, summary = diff_paragraphs(a, b) + + types = {c["text"]: c["type"] for c in chunks} + assert types["Alpha paragraph."] == "unchanged" + assert types["Beta paragraph."] == "removed" + assert types["Delta paragraph."] == "added" + assert summary["added_chunks"] == 1 + assert summary["removed_chunks"] == 1 + assert summary["unchanged_chunks"] == 2 # Alpha + Gamma + + +def test_diff_paragraphs_replace_renders_as_remove_then_insert(): + a = ["We face intense competition."] + b = ["We face intense competition from new market entrants."] + chunks, summary = diff_paragraphs(a, b) + + assert [c["type"] for c in chunks] == ["removed", "added"] + assert summary["removed_chunks"] == 1 + assert summary["added_chunks"] == 1 + + +def test_diff_paragraphs_identical_input_is_all_unchanged(): + a = ["One.", "Two.", "Three."] + chunks, summary = diff_paragraphs(a, list(a)) + assert all(c["type"] == "unchanged" for c in chunks) + assert summary["added_chunks"] == 0 + assert summary["removed_chunks"] == 0 + assert summary["unchanged_chunks"] == 3 + + +def test_cosine_similarity_identical_text_is_100(): + text = "Our business depends on continued demand for our products and services." + assert cosine_similarity_score(text, text) == 100.0 + + +def test_cosine_similarity_disjoint_text_is_zero(): + assert cosine_similarity_score("apple banana cherry", "xylophone zeppelin quokka") == 0.0 + + +def test_cosine_similarity_empty_text_is_zero(): + assert cosine_similarity_score("", "some text here") == 0.0 + assert cosine_similarity_score("some text here", "") == 0.0 + + +def test_fog_index_higher_for_longer_more_complex_sentences(): + simple = "We are a company. We sell goods. We make money." + complex_text = ( + "Our multinational organization systematically evaluates macroeconomic " + "considerations affecting operational profitability across jurisdictions." + ) + simple_fog = fog_index(simple) + complex_fog = fog_index(complex_text) + assert simple_fog is not None and complex_fog is not None + assert complex_fog > simple_fog + + +def test_fog_index_empty_text_returns_none(): + assert fog_index("") is None + + +def test_compute_analyst_metrics_word_count_change_pct(): + a = "one two three four" + b = "one two three four five six" + metrics = compute_analyst_metrics(a, b) + assert metrics["word_count_from"] == 4 + assert metrics["word_count_to"] == 6 + assert math.isclose(metrics["word_count_change_pct"], 50.0, abs_tol=0.1) diff --git a/backend/tests/test_extract.py b/backend/tests/test_extract.py new file mode 100644 index 0000000..5ec5a2c --- /dev/null +++ b/backend/tests/test_extract.py @@ -0,0 +1,83 @@ +from backend.app.extract import extract_item_1a, html_to_text, is_heading_paragraph, split_paragraphs + +FILLER = "This is a sentence about our business operations and market conditions. " * 6 + + +def _standalone_heading_doc(): + body_lines = [FILLER for _ in range(30)] + return "\n".join([ + "Item 1A. Risk Factors.", + *body_lines, + "Item 1B. Unresolved Staff Comments.", + "Nothing to report.", + ]) + + +def test_standalone_heading_is_isolated(): + section = extract_item_1a(_standalone_heading_doc()) + assert section is not None + assert "Item 1A" not in section # heading line itself is dropped + assert "Item 1B" not in section # end boundary excluded + assert FILLER.strip() in section + + +def test_toc_entry_is_not_mistaken_for_the_real_heading(): + # A table-of-contents entry sits right next to the *next* TOC entry -- + # too close together to be real prose -- so it must be skipped in favor + # of the real heading later in the document. + toc = "\n".join([ + "Item 1A. Risk Factors.", + "Item 1B. Unresolved Staff Comments.", + ]) + real_section = _standalone_heading_doc() + doc = toc + "\n" + real_section + section = extract_item_1a(doc) + assert section is not None + assert FILLER.strip() in section + + +def test_runin_caps_heading_fallback(): + body_lines = [FILLER for _ in range(30)] + doc = "\n".join([ + "RISK FACTORS. " + FILLER, + *body_lines, + "UNRESOLVED STAFF COMMENTS.", + ]) + section = extract_item_1a(doc) + assert section is not None + assert section.startswith("RISK FACTORS.") + + +def test_no_isolable_section_returns_none(): + doc = "Just some ordinary filing prose with no recognizable Item 1A heading anywhere in it." + assert extract_item_1a(doc) is None + + +def test_short_section_is_rejected_even_if_headings_match(): + doc = "\n".join([ + "Item 1A. Risk Factors.", + *(["A short line."] * 25), + "Item 1B. Unresolved Staff Comments.", + ]) + assert extract_item_1a(doc) is None + + +def test_html_to_text_preserves_paragraph_breaks_and_drops_page_footers(): + html = "First paragraph.
Second paragraph.
" \ + "| Period | ${escapeHtml(opts.seriesLabel)} |
|---|---|
| ${escapeHtml(p.label)} | ${escapeHtml(opts.format(p.value))} |
Browse how well-known public companies' disclosed risk factors (Item 1A) have - changed, filing over filing — a plain text diff, not a prediction.
-A GitHub pull-request diff, applied to the risk factors public companies disclose to + the SEC — every added, removed, and unchanged sentence, filing over filing.
+${escapeHtml(text)}
Generated by Claude from the diff above. Not guaranteed to be accurate -- verify against the original filings linked above.
`; }) .catch((e) => { panel.innerHTML = ` -Couldn't generate a summary: ${escapeHtml(e.message)}
`; }); } diff --git a/frontend/js/home.js b/frontend/js/home.js index 1d423c8..84888ad 100644 --- a/frontend/js/home.js +++ b/frontend/js/home.js @@ -1,4 +1,4 @@ -import { fetchCompanies, fetchCompany, fetchDiff, fetchMeta, importCompany, searchCompanies } from "./api.js"; +import { fetchCompanies, fetchCompany, fetchDiff, fetchMeta, importCompany, searchCompanies, searchFilingText } from "./api.js"; import { countUp } from "./animate.js"; function escapeHtml(s) { @@ -58,6 +58,7 @@ export async function initRail(controlsEl, listEl) { + Search filing text → `; } @@ -264,3 +265,62 @@ export function renderOverview(container, rail) { loadHeroExcerpt(container, topMovers[0].ticker); } } + +// --- Full-text search across every real, already-ingested filing's Item 1A +// text -- distinct from the rail's ticker/name filter above. --- +export function renderTextSearch(container, initialQuery) { + container.innerHTML = ` +Full text search over the real, already-extracted Item 1A section of every + indexed company -- e.g. "cybersecurity", "supply chain", "artificial intelligence".
+ + +…${highlight(escapeHtml(r.snippet), q.trim())}
+ `).join(""); + } + + input.addEventListener("input", () => { + clearTimeout(debounce); + debounce = setTimeout(() => runSearch(input.value), 300); + }); + input.focus(); + if (initialQuery) runSearch(initialQuery); +} diff --git a/frontend/js/main.js b/frontend/js/main.js index d415205..9ca8c0e 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -1,5 +1,5 @@ import { getApiKey, setApiKey, onApiKeyChange } from "./state.js"; -import { initRail, renderOverview } from "./home.js"; +import { initRail, renderOverview, renderTextSearch } from "./home.js"; import { renderCompany } from "./company.js"; const mainPanel = document.getElementById("main-panel"); @@ -18,6 +18,9 @@ function parseRoute() { if (parts[0] === "company" && parts[1]) { return { view: "company", ticker: decodeURIComponent(parts[1]) }; } + if (parts[0] === "search") { + return { view: "search", q: parts[1] ? decodeURIComponent(parts[1]) : "" }; + } return { view: "home" }; } @@ -31,6 +34,10 @@ async function render() { railApi.setActive(route.ticker); railMobileLabel.textContent = route.ticker; await renderCompany(mainPanel, route.ticker); + } else if (route.view === "search") { + railApi.setActive(null); + railMobileLabel.textContent = "Search filing text"; + renderTextSearch(mainPanel, route.q); } else { railApi.setActive(null); railMobileLabel.textContent = "All companies"; From 76d1256e01a14bd40b01deaf8b456fa0611b3c10 Mon Sep 17 00:00:00 2001 From: Ayushmangela${escapeHtml(peerTicker)} doesn't have enough filings to compare yet.
`; + return; + } + const [thisDiff, peerDiff] = await Promise.all([ + fetchDiff(ticker, thisLatest[0].filing_id, thisLatest[1].filing_id), + fetchDiff(peerTicker, peerLatest[0].filing_id, peerLatest[1].filing_id), + ]); + const tm = thisDiff.analyst_metrics; + const pm = peerDiff.analyst_metrics; + const rows = [ + ["Textual similarity", tm.similarity_score != null ? `${tm.similarity_score.toFixed(1)}%` : "—", pm.similarity_score != null ? `${pm.similarity_score.toFixed(1)}%` : "—"], + ["Fog readability", tm.fog_index_to?.toFixed(1) ?? "—", pm.fog_index_to?.toFixed(1) ?? "—"], + ["Item 1A length", tm.word_count_to?.toLocaleString() ?? "—", pm.word_count_to?.toLocaleString() ?? "—"], + ]; + result.innerHTML = ` +| ${escapeHtml(ticker)} | ${escapeHtml(peerTicker)} | |
|---|---|---|
| ${label} | ${a} | ${b} |
Each company's own most recent filing-over-filing comparison, fetched fresh — not the pair currently selected above.
`; + } catch (e) { + result.innerHTML = `Couldn't load ${escapeHtml(peerTicker)}: ${escapeHtml(e.message)}
`; + } + }); +} + function renderLocalSummaryPanel(local) { if (!local) return ""; const date = new Date(local.generated_at); @@ -313,7 +385,7 @@ function setupScrollspy(diffBodyEl, sectionNavEl) { headingEls.forEach((el) => scrollspyObserver.observe(el)); } -function renderDiffRegion(container, ticker, fromPeriod, toPeriod, diff) { +function renderDiffRegion(container, ticker, sector, fromPeriod, toPeriod, diff) { const s = diff.summary; const m = diff.analyst_metrics; const aiKey = getApiKey(); @@ -323,16 +395,25 @@ function renderDiffRegion(container, ticker, fromPeriod, toPeriod, diff) {