diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..a67da80 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "sec-tracker", + "runtimeExecutable": "venv/bin/uvicorn", + "runtimeArgs": ["backend.app.main:app", "--port", "8420"], + "port": 8420 + } + ] +} diff --git a/.claude/skills/frontend-design/LICENSE.txt b/.claude/skills/frontend-design/LICENSE.txt new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/.claude/skills/frontend-design/LICENSE.txt @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/.claude/skills/frontend-design/SKILL.md b/.claude/skills/frontend-design/SKILL.md new file mode 100644 index 0000000..decdff4 --- /dev/null +++ b/.claude/skills/frontend-design/SKILL.md @@ -0,0 +1,55 @@ +--- +name: frontend-design +description: Guidance for distinctive, intentional visual design when building new UI or reshaping an existing one. Helps with aesthetic direction, typography, and making choices that don't read as templated defaults. +license: Complete terms in LICENSE.txt +--- + +# Frontend Design + +Approach this as the design lead at a small studio known for giving every client a visual identity that could not be mistaken for anyone else's. This client has already rejected proposals that felt templated, and is paying for a distinctive point of view: make deliberate, opinionated choices about palette, typography, and layout that are specific to this brief, and take one real aesthetic risk you can justify. + +## Ground it in the subject + +If the brief does not pin down what the product or subject is, pin it yourself before designing: name one concrete subject, its audience, and the page's single job, and state your choice. If there's any information in your memory about the human's preferences, context about what they're building, or designs you've made before – use that as a hint. The subject's own world, its materials, instruments, artifacts, and vernacular, is where distinctive choices come from. Build with the brief's real content and subject matter throughout. + +## Design principles + +For web designs, the hero is a thesis. Open with the most characteristic thing in the subject's world, in whatever form makes sense for it: a headline, an image, an animation, a live demo, an interactive moment. Be deliberate with your choice: a big number with a small label, supporting stats, and a gradient accent is the template answer, only use if that's truly the best option. + +Typography carries the personality of the page. Pair the display and body faces deliberately, not the same families you would reach for on any other project, and set a clear type scale with intentional weights, widths, and spacing. Make the type treatment itself a memorable part of the design, not a neutral delivery vehicle for the content. + +Structure is information. Structural devices, numbering, eyebrows, dividers, labels, should encode something true about the content, not decorate it. Many generic designs use numbered markers (01 / 02 / 03), but that's only appropriate if the content actually is a sequence - like a real process or a typed timeline where order carries information the reader needs. Question if choices like numbered markers actually make sense before incorporating them. + +Leverage motion deliberately. Think about where and if animation can serve the subject: a page-load sequence, a scroll-triggered reveal, hover micro-interactions, ambient atmosphere. An orchestrated moment usually lands harder than scattered effects; choose what the direction calls for. However, sometimes less is more, and extra animation contributes to the feeling that the design is AI-generated. + +Match complexity to the vision. Maximalist directions need elaborate execution; minimal directions need precision in spacing, type, and detail. Elegance is executing the chosen vision well. + +Consider written content carefully. Often a design brief may not contain real content, and it's up to you to come up with copy. Copy can make a design feel as templated as the design itself. See the below section on writing for more guidance. + +## Process: brainstorm, explore, plan, critique, build, critique again + +For calibration: AI-generated design right now clusters around three looks: (1) a warm cream background (near #F4F1EA) with a high-contrast serif display and a terracotta accent; (2) a near-black background with a single bright acid-green or vermilion accent; (3) a broadsheet-style layout with hairline rules, zero border-radius, and dense newspaper-like columns. All three are legitimate for some briefs, but they are defaults rather than choices, and they appear regardless of subject. Where the brief pins down a visual direction, follow it exactly — the brief's own words always win, including when it asks for one of these looks. Where it leaves an axis free, don't spend that freedom on one of these defaults. Just like a human designer who's hired, there's often a careful balance between doing what you're good at and taking each project as a chance to experiment and learn. + +Work in two passes. First, brainstorm a short design plan based on the human's design brief: create a compact token system with color, type, layout, and signature. Color: describe the palette as 4–6 named hex values. Type: the typefaces for 2+ roles (a characterful display face that's used with restraint, a complementary body face, and a utility face for captions or data if needed). Layout: a layout concept, using one-sentence prose descriptions and ASCII wireframes to ideate and compare. Signature: the single unique element this page will be remembered by that embodies the brief in an appropriate way. + +Then review that plan against the brief before building: if any part of it reads like the generic default you would produce for any similar page (work through a similar prompt to see if you arrive somewhere similar) rather than a choice made for this specific brief — revise that part, say what you changed and why. Only after you've confirmed the relative uniqueness of your design plan should you start to write the code, following the revised plan exactly and deriving every color and type decision from it. + +When writing the code, be careful of structuring your CSS selector specificities. It's easy to generate CSS classes that cancel each other out (especially with a type-based selector like .section and a element-based selector like .cta). This can happen often with paddings/margins between sections. + +Try to do a lot of this planning and iteration in your thinking, and only show ideas to the user when you have higher confidence it'll delight them. + +## Restraint and self-critique + +Spend your boldness in one place. Let the signature element be the one memorable thing, keep everything around it quiet and disciplined, and cut any decoration that does not serve the brief. Not taking a risk can be a risk itself! Build to a quality floor without announcing it: responsive down to mobile, visible keyboard focus, reduced motion respected. Critique your own work as you build, taking screenshots if your environment supports it – a picture is worth 1000 tokens. Consider Chanel's advice: before leaving the house, take a look in the mirror and remove one accessory. Human creators have memory and always try to do something new, so if you have a space to quickly jot down notes about what you've tried, it can help you in future passes. + +## More on writing in design + +Words appear in a design for one reason: to make it easier to understand, and therefore easier to use. They are design material, not decoration. Bring the same intentionality to copy that you would bring to spacing and color. Before writing anything, ask what the design needs to say, and how it can best be said to help the person navigate the experience. + +Write from the end user's side of the screen. Name things by what people control and recognize, never by how the system is built. A person manages notifications, not webhook config. Describe what something does in plain terms rather than selling it. Being specific is always better than being clever. + +Use active voice as default. A control should say exactly what happens when it's used: "Save changes," not "Submit." An action keeps the same name through the whole flow, so the button that says "Publish" produces a toast that says "Published." The vocabulary of an interface is the signposting for someone navigating the product. Cohesion and consistency are how people learn their way around. + +Treat failure and emptiness as moments for direction, not mood. Explain what went wrong and how to fix it, in the interface's voice rather than a person's. Errors don't apologize, and they are never vague about what happened. An empty screen is an invitation to act. + +Keep the register conversational and tuned: plain verbs, sentence case, no filler, with tone matched to the brand and the audience. Let each element do exactly one job. A label labels, an example demonstrates, and nothing quietly does double duty. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..ae6e5af --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,18 @@ +name: tests + +on: + push: + pull_request: + +jobs: + pytest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies + run: pip install -r requirements.txt + - name: Run tests + run: pytest backend/tests/ -v diff --git a/README.md b/README.md index e6d3309..535d6b9 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ SEC EDGAR 10-K BYOK optional LLM layer License: MIT + pytest test suite

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,15 @@ 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. Beyond a single company's own history, a company's numbers are +also placed against real sector peers (a percentile rank against every other same-sector company's +own latest metrics — arithmetic over data already in the database, not a model output), and a +full-text search reaches across every ingested filing's real Item 1A text at once. 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 +60,8 @@ handling** for the one optional AI feature in the app. - [Why this exists](#why-this-exists) - [Architecture](#architecture) - [Installation / running locally](#installation--running-locally) +- [Local AI summaries (optional)](#local-ai-summaries-optional) +- [Testing](#testing) - [Quickstart](#quickstart) - [How the diffing works](#how-the-diffing-works) - [Data handling & privacy](#data-handling--privacy) @@ -107,10 +118,29 @@ flowchart LR Notice what's *not* in the second diagram: this project's own FastAPI server. There is no passthrough endpoint or proxy — the request goes straight from your browser to Anthropic. +**3. An optional local-LLM summary layer — precomputed offline, served to everyone:** + +```mermaid +flowchart LR + D[("SQLite
diffs table")] --> S["summarize_local.py
one pass over every diff"] + S -->|"HTTP, localhost only"| O["Ollama server
Qwen3 8B"] + O --> S + S --> L[("SQLite
local_summaries table")] + L --> F["FastAPI
/diff response"] + F --> G3["Frontend
labeled 'Local AI summary'"] +``` + +This is the offline-batch counterpart to the BYOK layer above, not a replacement for it: BYOK stays +live and per-user because it needs *your* key, while this one runs once, against a model you host +yourself, and the result is stored and served to every visitor regardless of whether they have a +key. It follows the same rule as the diffing pipeline itself — nothing LLM-shaped runs in the live +request path, only a read of an already-computed row. See +[`backend/app/summarize_local.py`](backend/app/summarize_local.py) and +[Local AI summaries](#local-ai-summaries-optional) below for how to run it. + ## 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 +171,75 @@ 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. +## Local AI summaries (optional) + +A second, independent AI feature from the BYOK one above: a plain-English summary generated once +per diff by a **local** model and stored, so it's visible to every visitor without them needing an +API key. Requires [Ollama](https://ollama.com) running on your own machine (this is not something +this project can run for you — an 8B model needs real local compute): + +```bash +ollama pull qwen3:8b +ollama serve # if it isn't already running + +python -m backend.app.summarize_local +``` + +The script walks every precomputed diff, skips any that already have a stored summary (safe to +re-run after `ingest.py` adds new filings), and stores the result in a `local_summaries` table — +never generated inside a live request. If it's already been run, the diff page shows a "Local AI +summary (qwen3:8b, precomputed)" panel, clearly distinguished from the live, per-user BYOK panel +above it. Skip this section entirely if you don't want it — everything else in the app works +identically without it. + +## 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 +The app is a persistent split-pane shell, not a home page + detail page: a left rail (search, +sector filter, sort, and the full company list) stays on screen the whole time, next to a main +panel that swaps between the overview and whichever company you're reading — closer to a PR file +list next to its diff than to separate site pages, since jumping between companies is the thing +you actually do repeatedly. Below ~860px the rail becomes a collapsible drawer under a toggle bar. + +1. The overview (what you land on) leads with a real excerpt from the biggest recent mover's actual + diff, and a "biggest recent changes" strip below it. +2. Search the rail for a ticker or name; it filters the list live. A search with no local match + offers to import that ticker live from SEC EDGAR — the same fetch/extract/diff pipeline as the + offline ingestion, just scoped to one company and run on request. **Search filing text** (link + below the rail's search box) instead searches the real extracted text of every ingested filing + at once, e.g. "cybersecurity" or "supply chain." +3. Click a company in the rail to open its detail view in the main panel — the rail stays put and + highlights your selection. 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. **Disclosure volume by section** shows real word counts grouped under the filing's own actual + section headings — not a score, just where the text (and the change) concentrated. +6. 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 - 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 - below the summary strip. -7. Toggle light/dark theme from the circular button in the top-right at any time. +7. Use the **Jump to section** sidebar to skip directly to a specific risk topic instead of + scrolling the whole document — it highlights your current section as you scroll. The + **Analyst metrics** panel above it gives textual similarity, Fog readability, and section length + (see [How the diffing works](#how-the-diffing-works)), plus — when at least 3 other companies + share the sector — a real percentile rank against those peers' own latest metrics. +8. If you've entered an Anthropic key, a live plain-English AI summary appears in its own panel. If + [`summarize_local.py`](#local-ai-summaries-optional) has been run, a separately-labeled + precomputed local-model summary appears too — both real, both clearly attributed to their + actual source. +9. Toggle light/dark theme from the circular button in the rail header at any time. ## How the diffing works @@ -224,6 +304,9 @@ logic favors transparent, reproducible arithmetic over a black box. AI layer only adds an optional paragraph on top of data you can already see and read yourself. - **No user accounts, no tracking, no saved preferences.** There is nothing to opt out of because nothing is collected. +- **The local AI summary layer never leaves your machine.** `summarize_local.py` talks only to + `127.0.0.1` (your own Ollama server) — no filing text is ever sent to a third party for it. The + resulting summary text is the only thing stored, in your own local `tracker.db`. ## Design principles @@ -247,9 +330,13 @@ accusation. The AI summary layer is explicitly instructed the same way. removals) a strikethrough in addition to its background color, and the diff palette uses blue/amber rather than red/green so it stays legible for the most common forms of color vision deficiency. -**Metrics inform, never conclude.** The analyst-metrics panel presents its three figures with their +**Metrics inform, never conclude.** The analyst-metrics panel presents its figures with their methodology cited in the UI itself; none of them roll up into a single score or a verdict — a low similarity score or a rising Fog index is a prompt to go read the diff, not a conclusion in itself. +The same discipline applies to the newer panels: peer percentiles are labeled as arithmetic against +real stored peer data, not a rating; the section breakdown is real word counts under the filing's +own real headings, never scored or color-graded by "severity." If a number is shown, it traces back +to a real computation over real stored data — nothing is invented to make a panel look fuller. ## API overview @@ -258,8 +345,11 @@ 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/{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 | +| `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`) + `summary` + `analyst_metrics` + `peer_percentile` (null with <3 sector peers) + `section_breakdown` + `local_summary` (null until `summarize_local.py` has run) between two periods | +| `GET` | `/api/search/text?q=` | Full-text search over every ingested filing's real Item 1A text, with a highlighted snippet per matching company | This backend never accepts or forwards an Anthropic API key — see [Data handling & privacy](#data-handling--privacy). The AI summary call happens entirely in the @@ -284,14 +374,20 @@ 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. +- **Frontend:** vanilla HTML/CSS/JS with ES modules, no build step, no framework — a persistent + split-pane shell ([`frontend/js/main.js`](frontend/js/main.js)) with a company rail that never + unmounts and a hash-routed main panel, serif/mono typography, and a 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/db.py b/backend/app/db.py index eb49430..2d674d5 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -52,6 +52,19 @@ key TEXT PRIMARY KEY, value TEXT NOT NULL ); + +-- Precomputed local-LLM summaries, generated offline by summarize_local.py +-- against a locally-running Ollama server -- never generated in the live +-- request path, the same precompute-once discipline as the `diffs` table. +CREATE TABLE IF NOT EXISTS local_summaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_filing_id INTEGER NOT NULL REFERENCES filings(id), + to_filing_id INTEGER NOT NULL REFERENCES filings(id), + summary TEXT NOT NULL, + model TEXT NOT NULL, + generated_at TEXT NOT NULL, + UNIQUE(from_filing_id, to_filing_id) +); """ diff --git a/backend/app/diffing.py b/backend/app/diffing.py index 61020d1..e2bd9c3 100644 --- a/backend/app/diffing.py +++ b/backend/app/diffing.py @@ -157,3 +157,51 @@ def compute_analyst_metrics(text_a: str, text_b: str) -> dict: "word_count_to": words_b, "word_count_change_pct": round(100 * (words_b - words_a) / words_a, 1) if words_a else None, } + + +def section_breakdown(chunks: list) -> list: + """Group a diff's chunks under the filing's own real section headings + (the same `heading` flag the section-jump nav uses) and report each + section's real word count and real added/removed word count. + + Deliberately not a "risk score" -- there is no judgment call here, just + counting words under whatever heading the filer itself wrote. A section + with no detected heading falls under "General disclosures" rather than + being dropped or invented a title. + """ + sections = [] + current_title = "General disclosures" + current_words = current_added = current_removed = 0 + + def _flush(): + if current_words > 0: + sections.append({ + "title": current_title, + "word_count": current_words, + "added_words": current_added, + "removed_words": current_removed, + }) + + for c in chunks: + text = c.get("text", "").strip() + if not text: + continue + words = len(_tokenize(text)) + if c.get("heading"): + _flush() + current_title = text.rstrip(" .:")[:60] + current_words = current_added = current_removed = 0 + continue + current_words += words + if c.get("type") == "added": + current_added += words + elif c.get("type") == "removed": + current_removed += words + _flush() + + total_words = sum(s["word_count"] for s in sections) or 1 + for s in sections: + s["share_pct"] = round(100 * s["word_count"] / total_words, 1) + + sections.sort(key=lambda s: s["word_count"], reverse=True) + return sections[:10] diff --git a/backend/app/extract.py b/backend/app/extract.py index e9e3b7f..6105a9f 100644 --- a/backend/app/extract.py +++ b/backend/app/extract.py @@ -136,6 +136,13 @@ def _best_pair(start_re, end_re, drop_start_line): _HEADER_LINE_RE = re.compile(r"^[A-Z0-9][A-Za-z0-9 ,.'/&\-]{0,90}$") +# Lines that pass the general "short, no closing punctuation, title-like" +# shape test above but are page furniture, not a real risk-topic heading -- +# a stray "Table of Contents" line repeated at every TOC entry, or a bare +# page number left over after a page break. Neither belongs in the +# section-jump nav or the section-breakdown grouping. +_BORING_HEADING_RE = re.compile(r"^(table of contents|contents)$", re.IGNORECASE) + def is_heading_paragraph(text: str) -> bool: """True if a paragraph reads as a risk-topic sub-heading (e.g. a bolded @@ -144,7 +151,11 @@ def is_heading_paragraph(text: str) -> bool: both to build the section-jump navigation and to style headings distinctly in the diff view.""" text = text.strip() - return bool(text) and len(text) < 100 and not text.endswith((".", ":", ";")) and bool(_HEADER_LINE_RE.match(text)) + if not text or len(text) >= 100 or text.endswith((".", ":", ";")): + return False + if text.isdigit() or _BORING_HEADING_RE.match(text): + return False + return bool(_HEADER_LINE_RE.match(text)) def split_paragraphs(section_text: str): diff --git a/backend/app/main.py b/backend/app/main.py index b7aa425..8fb141d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,7 +14,9 @@ from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse -from . import db +from . import acquisition, db +from .diffing import section_breakdown +from .extract import is_heading_paragraph FRONTEND_DIR = Path(__file__).resolve().parent.parent.parent / "frontend" @@ -23,7 +25,7 @@ app.add_middleware( CORSMiddleware, allow_origins=["*"], - allow_methods=["GET"], + allow_methods=["GET", "POST"], allow_headers=["*"], ) @@ -32,6 +34,33 @@ def _db_ready() -> bool: return db.DB_PATH.exists() +def _sector_peer_latest_metrics(conn, sector: str): + """One row per sector peer: their most recent filing-over-filing diff + metrics. Used to place a company's current numbers in context -- real + percentile rank against real peers, not an invented score.""" + rows = conn.execute( + """SELECT d.ticker, d.similarity_score, d.fog_index_to, d.word_count_to, tf.filing_date + FROM diffs d + JOIN companies c ON c.ticker = d.ticker + JOIN filings tf ON tf.id = d.to_filing_id + WHERE c.sector = ?""", + (sector,), + ).fetchall() + latest = {} + for r in rows: + if r["ticker"] not in latest or r["filing_date"] > latest[r["ticker"]]["filing_date"]: + latest[r["ticker"]] = r + return list(latest.values()) + + +def _percentile_rank(value, population): + vals = [v for v in population if v is not None] + if value is None or not vals: + return None + below = sum(1 for v in vals if v < value) + return round(100 * below / len(vals)) + + @app.get("/api/health") def health(): return {"status": "ok", "database_populated": _db_ready()} @@ -83,6 +112,97 @@ 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.get("/api/search/text") +def search_text(q: str = "", limit: int = 20): + """Full-text search over every real, already-ingested filing's Item 1A + text -- e.g. "which companies disclose a cybersecurity risk". A plain + SQL LIKE scan is fine at this corpus size (a few hundred filings).""" + q = q.strip() + if len(q) < 3: + return {"query": q, "results": []} + + conn = db.get_conn() + rows = conn.execute( + """SELECT f.ticker, c.name, f.period_of_report, f.filing_date, f.risk_factor_text + FROM filings f JOIN companies c ON c.ticker = f.ticker + WHERE f.risk_factor_text LIKE ? + ORDER BY f.filing_date DESC""", + (f"%{q}%",), + ).fetchall() + conn.close() + + seen = set() + results = [] + q_lower = q.lower() + for r in rows: + if r["ticker"] in seen: + continue + text = r["risk_factor_text"] + idx = text.lower().find(q_lower) + if idx < 0: + continue + start = max(0, idx - 60) + snippet = ("…" if start > 0 else "") + text[start:idx + len(q) + 100].strip() + "…" + seen.add(r["ticker"]) + results.append({ + "ticker": r["ticker"], + "name": r["name"], + "label": (r["period_of_report"] or r["filing_date"])[:4], + "snippet": snippet, + }) + if len(results) >= limit: + break + return {"query": q, "results": results} + + +@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 +217,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 +258,7 @@ def company_detail(ticker: str): } for f in filings ], + "trend": trend, } @@ -137,8 +277,35 @@ def company_diff(ticker: str, from_id: int, to_id: int): from_f = conn.execute("SELECT filing_date, period_of_report, source_url FROM filings WHERE id = ?", (from_id,)).fetchone() to_f = conn.execute("SELECT filing_date, period_of_report, source_url FROM filings WHERE id = ?", (to_id,)).fetchone() + + comp_row = conn.execute("SELECT sector FROM companies WHERE ticker = ?", (ticker,)).fetchone() + peer_percentile = None + if comp_row and comp_row["sector"]: + peers = _sector_peer_latest_metrics(conn, comp_row["sector"]) + if len(peers) >= 3: + peer_percentile = { + "sector": comp_row["sector"], + "peer_count": len(peers), + "similarity_score": _percentile_rank(diff_row["similarity_score"], [p["similarity_score"] for p in peers]), + "fog_index": _percentile_rank(diff_row["fog_index_to"], [p["fog_index_to"] for p in peers]), + "word_count": _percentile_rank(diff_row["word_count_to"], [p["word_count_to"] for p in peers]), + } + + local_summary_row = conn.execute( + "SELECT summary, model, generated_at FROM local_summaries WHERE from_filing_id = ? AND to_filing_id = ?", + (from_id, to_id), + ).fetchone() conn.close() + chunks = json.loads(diff_row["chunks_json"]) + # Re-derive the heading flag with the current heuristic on every read, + # rather than trusting whatever was baked into chunks_json at ingest + # time -- cheap (a regex over already-loaded text), and it means a + # heuristic fix like excluding "Table of Contents" lines takes effect + # immediately for already-ingested filings, not just future ones. + for c in chunks: + c["heading"] = is_heading_paragraph(c["text"]) + return { "ticker": ticker, "from_period": { @@ -166,7 +333,10 @@ def company_diff(ticker: str, from_id: int, to_id: int): "word_count_to": diff_row["word_count_to"], "word_count_change_pct": diff_row["word_count_change_pct"], }, - "chunks": json.loads(diff_row["chunks_json"]), + "peer_percentile": peer_percentile, + "section_breakdown": section_breakdown(chunks), + "local_summary": dict(local_summary_row) if local_summary_row else None, + "chunks": chunks, } diff --git a/backend/app/summarize_local.py b/backend/app/summarize_local.py new file mode 100644 index 0000000..8347914 --- /dev/null +++ b/backend/app/summarize_local.py @@ -0,0 +1,132 @@ +"""Precompute plain-English diff summaries with a local LLM, once per +filing-over-filing pair, so every visitor sees a real summary without +needing their own Anthropic key -- the local counterpart to the frontend's +BYOK Claude summary (frontend/js/llm.js), which stays live/per-user because +it needs the individual user's own key. + +Run offline, on demand, same pattern as ingest.py: + + python -m backend.app.summarize_local + +Requires a local Ollama server (https://ollama.com) with a model pulled, +e.g.: + + ollama pull qwen3:8b + ollama serve # if not already running + +This deliberately never runs in the live request path -- generating a +summary with an 8B model takes seconds, which is fine for a batch job and +wrong for a request handler. Safe to re-run: it skips pairs that already +have a stored summary. +""" + +import sys +from datetime import datetime, timezone + +import httpx + +from . import db + +OLLAMA_HOST = "http://127.0.0.1:11434" +MODEL = "qwen2.5:3b" +REQUEST_TIMEOUT_SECONDS = 120.0 + + +def _build_prompt(ticker: str, from_label: str, to_label: str, chunks: list) -> str: + added = [c["text"] for c in chunks if c.get("type") == "added" and not c.get("heading")][:8] + removed = [c["text"] for c in chunks if c.get("type") == "removed" and not c.get("heading")][:6] + return ( + f"You are explaining a change in {ticker}'s SEC 10-K \"Risk Factors\" (Item 1A) " + f"section between the {from_label} and {to_label} filings.\n\n" + f"ADDED language ({len(added)} paragraph(s)):\n" + + ("\n".join(f"- {t}" for t in added) if added else "(none)") + + f"\n\nREMOVED language ({len(removed)} paragraph(s)):\n" + + ("\n".join(f"- {t}" for t in removed) if removed else "(none)") + + "\n\nIn 3-5 sentences of plain English, explain what substantively changed and why " + "it might matter to a reader tracking this company's disclosed risks. Describe what " + "changed in the disclosed language -- do not predict stock performance, do not give " + "investment advice, and do not claim the change implies anything definitive about the " + "company's actual condition." + ) + + +def _check_ollama(client: httpx.Client) -> bool: + try: + resp = client.get(f"{OLLAMA_HOST}/api/tags", timeout=5.0) + resp.raise_for_status() + models = [m.get("name", "") for m in resp.json().get("models", [])] + if not any(MODEL.split(":")[0] in m for m in models): + print(f"'{MODEL}' not found in Ollama's installed models: {models}") + print(f"Run: ollama pull {MODEL}") + return False + return True + except httpx.HTTPError as e: + print(f"Couldn't reach Ollama at {OLLAMA_HOST}: {e}") + print("Start it with `ollama serve`, then re-run this script.") + return False + + +def _generate(client: httpx.Client, prompt: str) -> str: + resp = client.post( + f"{OLLAMA_HOST}/api/generate", + json={"model": MODEL, "prompt": prompt, "stream": False}, + timeout=REQUEST_TIMEOUT_SECONDS, + ) + resp.raise_for_status() + return resp.json()["response"].strip() + + +def main(): + import json as jsonlib + + conn = db.init_db(reset=False) + client = httpx.Client() + + if not _check_ollama(client): + sys.exit(1) + + pairs = conn.execute( + """SELECT d.ticker, d.from_filing_id, d.to_filing_id, d.chunks_json, + ff.period_of_report as from_por, ff.filing_date as from_date, + tf.period_of_report as to_por, tf.filing_date as to_date + FROM diffs d + JOIN filings ff ON ff.id = d.from_filing_id + JOIN filings tf ON tf.id = d.to_filing_id + LEFT JOIN local_summaries ls + ON ls.from_filing_id = d.from_filing_id AND ls.to_filing_id = d.to_filing_id + WHERE ls.id IS NULL + ORDER BY d.ticker""" + ).fetchall() + + if not pairs: + print("Every precomputed diff already has a local summary. Nothing to do.") + return + + print(f"Generating {len(pairs)} summar{'y' if len(pairs) == 1 else 'ies'} with {MODEL}...") + done = 0 + for row in pairs: + chunks = jsonlib.loads(row["chunks_json"]) + from_label = (row["from_por"] or row["from_date"])[:4] + to_label = (row["to_por"] or row["to_date"])[:4] + prompt = _build_prompt(row["ticker"], from_label, to_label, chunks) + try: + summary = _generate(client, prompt) + except httpx.HTTPError as e: + print(f" [{row['ticker']}] {from_label}->{to_label}: FAILED ({e}), skipping") + continue + + conn.execute( + """INSERT INTO local_summaries (from_filing_id, to_filing_id, summary, model, generated_at) + VALUES (?, ?, ?, ?, ?)""", + (row["from_filing_id"], row["to_filing_id"], summary, MODEL, + datetime.now(timezone.utc).isoformat()), + ) + conn.commit() + done += 1 + print(f" [{row['ticker']}] {from_label}->{to_label}: stored ({len(summary)} chars)") + + print(f"\nDone. {done}/{len(pairs)} summaries stored.") + + +if __name__ == "__main__": + main() 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..14f3273 --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,205 @@ +"""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 + + # New real-data fields: section_breakdown always present (grouped from + # the filing's own headings); peer_percentile is null with <3 sector + # peers (only one company seeded here); local_summary null until the + # offline summarize_local.py batch job has actually run. + assert isinstance(diff["section_breakdown"], list) + assert len(diff["section_breakdown"]) >= 1 + assert diff["peer_percentile"] is None + assert diff["local_summary"] is None + + +def test_diff_reclassifies_toc_and_page_numbers_as_non_headings(test_db): + # Old-format stored data (or data ingested before the heading heuristic + # was fixed) can have `heading: true` baked into chunks_json for a + # "Table of Contents" line or a bare page number. The API must + # re-derive the flag on every read rather than trust the stored value. + import json as jsonlib + + conn = db.get_conn() + from_id, to_id = seed_company_with_diff(conn) + stale_chunks = [ + {"type": "unchanged", "text": "Table of Contents", "heading": True}, + {"type": "unchanged", "text": "24", "heading": True}, + {"type": "unchanged", "text": "Cybersecurity Risks", "heading": True}, + {"type": "unchanged", "text": "We face cyber threats.", "heading": False}, + ] + conn.execute( + "UPDATE diffs SET chunks_json = ? WHERE from_filing_id = ? AND to_filing_id = ?", + (jsonlib.dumps(stale_chunks), from_id, to_id), + ) + conn.commit() + conn.close() + + resp = _client().get(f"/api/companies/ACME/diff?from_id={from_id}&to_id={to_id}") + assert resp.status_code == 200 + chunks = resp.json()["chunks"] + headings = {c["text"]: c["heading"] for c in chunks} + assert headings["Table of Contents"] is False + assert headings["24"] is False + assert headings["Cybersecurity Risks"] is True + + titles = [s["title"] for s in resp.json()["section_breakdown"]] + assert "Table of Contents" not in titles + assert "24" not in titles + + +def test_peer_percentile_appears_with_enough_sector_peers(test_db): + conn = db.get_conn() + from_id, to_id = seed_company_with_diff(conn, ticker="ACME", sector="Technology") + seed_company_with_diff(conn, ticker="BETA", sector="Technology") + seed_company_with_diff(conn, ticker="GAMMA", sector="Technology") + conn.close() + + resp = _client().get(f"/api/companies/ACME/diff?from_id={from_id}&to_id={to_id}") + assert resp.status_code == 200 + peer = resp.json()["peer_percentile"] + assert peer is not None + assert peer["sector"] == "Technology" + assert peer["peer_count"] == 3 + assert 0 <= peer["similarity_score"] <= 100 + + +def test_search_text_finds_real_filing_content(test_db): + conn = db.get_conn() + seed_company_with_diff(conn) + conn.close() + + resp = _client().get("/api/search/text?q=competition") + assert resp.status_code == 200 + body = resp.json() + assert any(r["ticker"] == "ACME" for r in body["results"]) + assert "competition" in body["results"][0]["snippet"].lower() + + +def test_search_text_short_query_returns_empty(test_db): + resp = _client().get("/api/search/text?q=ab") + assert resp.status_code == 200 + assert resp.json()["results"] == [] + + +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..81394fb --- /dev/null +++ b/backend/tests/test_diffing.py @@ -0,0 +1,115 @@ +import math + +from backend.app.diffing import ( + compute_analyst_metrics, + cosine_similarity_score, + diff_paragraphs, + fog_index, + section_breakdown, +) + + +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) + + +def test_section_breakdown_groups_under_real_headings(): + chunks = [ + {"type": "unchanged", "text": "Cybersecurity Risks", "heading": True}, + {"type": "unchanged", "text": "We face risks from cyber attacks on our systems.", "heading": False}, + {"type": "added", "text": "New disclosure about a recent security incident response.", "heading": False}, + {"type": "unchanged", "text": "Regulatory Risks", "heading": True}, + {"type": "removed", "text": "Old language about a since-resolved regulatory matter.", "heading": False}, + ] + sections = section_breakdown(chunks) + titles = [s["title"] for s in sections] + assert "Cybersecurity Risks" in titles + assert "Regulatory Risks" in titles + + cyber = next(s for s in sections if s["title"] == "Cybersecurity Risks") + assert cyber["added_words"] > 0 + assert cyber["removed_words"] == 0 + + reg = next(s for s in sections if s["title"] == "Regulatory Risks") + assert reg["removed_words"] > 0 + + assert math.isclose(sum(s["share_pct"] for s in sections), 100.0, abs_tol=0.5) + + +def test_section_breakdown_no_headings_falls_under_general(): + chunks = [{"type": "unchanged", "text": "Just a plain paragraph with no heading before it.", "heading": False}] + sections = section_breakdown(chunks) + assert len(sections) == 1 + assert sections[0]["title"] == "General disclosures" + + +def test_section_breakdown_empty_chunks_returns_empty(): + assert section_breakdown([]) == [] diff --git a/backend/tests/test_extract.py b/backend/tests/test_extract.py new file mode 100644 index 0000000..d006463 --- /dev/null +++ b/backend/tests/test_extract.py @@ -0,0 +1,95 @@ +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.

" \ + "
Acme Inc. | 2024 Form 10-K | 5
" + text = html_to_text(html) + lines = text.split("\n") + assert "First paragraph." in lines + assert "Second paragraph." in lines + assert not any("Form 10-K" in ln for ln in lines) + + +def test_is_heading_paragraph(): + assert is_heading_paragraph("Cybersecurity and Data Privacy Risks") + assert not is_heading_paragraph("We face significant competition in our core markets.") + assert not is_heading_paragraph("") + + +def test_is_heading_paragraph_excludes_toc_and_page_numbers(): + # Real-world artifacts: a repeated "Table of Contents" line and a bare + # page number both pass the general short/title-like shape test but + # aren't real risk-topic headings, and used to pollute the section-jump + # nav and section-breakdown grouping with junk entries. + assert not is_heading_paragraph("Table of Contents") + assert not is_heading_paragraph("TABLE OF CONTENTS") + assert not is_heading_paragraph("Contents") + assert not is_heading_paragraph("24") + assert not is_heading_paragraph("136") + + +def test_split_paragraphs_drops_blank_lines(): + section = "First.\n\n\nSecond.\n" + assert split_paragraphs(section) == ["First.", "Second."] diff --git a/frontend/css/style.css b/frontend/css/style.css index 9ecc651..a73a216 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -1,15 +1,32 @@ -/* ---------- Design tokens ---------- */ +/* ---------- Design tokens ---------- + Grounded in the subject: this reads legal redlines of SEC filings, so the + palette is a warm paper/ink tone rather than a cold SaaS gray, and every + ticker, date, and figure is set in a monospace face -- the way filings, + tickers, and ledgers actually set their numerals -- instead of the same + proportional sans as the prose around it. */ :root { - --bg: #fafaf9; + --bg: #f8f8fa; --bg-elevated: #ffffff; - --text: #1c1c1e; - --text-muted: #5b5b60; - --text-faint: #8a8a90; - --border: #e4e4e2; + --text: #1b1c22; + --text-muted: #63656f; + --text-faint: #94969f; + --border: #e6e6ec; --accent: #3454d1; --accent-hover: #2740a8; --accent-soft: #eaeeff; + /* Icon-badge palette for stat tiles/cards -- distinct hues per tile so a + row of stats reads at a glance, each still paired with a real number + and label (never color standing in for a judgment). */ + --badge-purple: #6c4fe0; + --badge-purple-soft: #efeafc; + --badge-orange: #d97b1f; + --badge-orange-soft: #fdf1e3; + --badge-blue: #2f6fd6; + --badge-blue-soft: #eaf1fd; + --badge-teal: #0d9080; + --badge-teal-soft: #e5f6f3; + /* Diff palette: blue/orange, not red/green, so it stays legible for the most common forms of color vision deficiency. Color is never the only cue -- every added/removed chunk also gets a +/- glyph and (for @@ -28,77 +45,102 @@ --feature: #0d8f83; --feature-soft: #e5f6f4; - --radius: 10px; - --shadow: 0 1px 2px rgba(20, 20, 30, 0.04), 0 4px 16px rgba(20, 20, 30, 0.05); + --radius: 8px; + --shadow: 0 1px 2px rgba(30, 25, 10, 0.05), 0 4px 16px rgba(30, 25, 10, 0.06); --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif; - --font-prose: Georgia, "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif; - --font-display: Georgia, "Iowan Old Style", "Palatino Linotype", serif; + --font-prose: "Source Serif 4", Georgia, "Iowan Old Style", "Palatino Linotype", serif; + --font-display: "Source Serif 4", Georgia, "Iowan Old Style", "Palatino Linotype", serif; + /* Every ticker, date, accession-style figure, and stat number uses this -- + the way an actual filing or a ledger sets its numerals, distinct from + the surrounding prose/UI type. */ + --font-mono: "IBM Plex Mono", "SF Mono", ui-monospace, Menlo, Consolas, monospace; } :root[data-theme="dark"] { - --bg: #101114; - --bg-elevated: #17181c; - --text: #eceef0; - --text-muted: #a3a5ab; - --text-faint: #74767c; - --border: #2a2b30; - --accent: #7691ff; - --accent-hover: #91a6ff; - --accent-soft: #1c2340; - - --added-bg: #14203f; - --added-border: #6f93ff; - --added-text: #cddcff; - --removed-bg: #3a2313; - --removed-border: #e0983f; - --removed-text: #ffdfb8; - --unchanged-text: #929499; - - --feature: #35c4b6; - --feature-soft: #103733; + --bg: #16140f; + --bg-elevated: #1e1b14; + --text: #ece7d9; + --text-muted: #a89f8a; + --text-faint: #776f5c; + --border: #35301f; + --accent: #6b82f0; + --accent-hover: #8497f5; + --accent-soft: #232544; + + --added-bg: #1c223f; + --added-border: #7c9dff; + --added-text: #d3ddff; + --removed-bg: #3a2413; + --removed-border: #e6a353; + --removed-text: #ffe2ba; + --unchanged-text: #948c76; + + --feature: #2ba99b; + --feature-soft: #17332f; + + --badge-purple: #8f7af5; + --badge-purple-soft: #29234a; + --badge-orange: #e6a353; + --badge-orange-soft: #3a2413; + --badge-blue: #7c9dff; + --badge-blue-soft: #1c223f; + --badge-teal: #2ba99b; + --badge-teal-soft: #17332f; } @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { - --bg: #101114; - --bg-elevated: #17181c; - --text: #eceef0; - --text-muted: #a3a5ab; - --text-faint: #74767c; - --border: #2a2b30; - --accent: #7691ff; - --accent-hover: #91a6ff; - --accent-soft: #1c2340; - - --added-bg: #14203f; - --added-border: #6f93ff; - --added-text: #cddcff; - --removed-bg: #3a2313; - --removed-border: #e0983f; - --removed-text: #ffdfb8; - --unchanged-text: #929499; + --bg: #16140f; + --bg-elevated: #1e1b14; + --text: #ece7d9; + --text-muted: #a89f8a; + --text-faint: #776f5c; + --border: #35301f; + --accent: #6b82f0; + --accent-hover: #8497f5; + --accent-soft: #232544; + + --added-bg: #1c223f; + --added-border: #7c9dff; + --added-text: #d3ddff; + --removed-bg: #3a2413; + --removed-border: #e6a353; + --removed-text: #ffe2ba; + --unchanged-text: #948c76; + + --feature: #2ba99b; + --feature-soft: #17332f; + --badge-purple: #8f7af5; + --badge-purple-soft: #29234a; + --badge-orange: #e6a353; + --badge-orange-soft: #3a2413; + --badge-blue: #7c9dff; + --badge-blue-soft: #1c223f; + --badge-teal: #2ba99b; + --badge-teal-soft: #17332f; } } * { box-sizing: border-box; } -html, body { margin: 0; padding: 0; } +html, body { margin: 0; padding: 0; height: 100%; } body { background: var(--bg); color: var(--text); font-family: var(--font-ui); line-height: 1.5; -webkit-font-smoothing: antialiased; + display: flex; + flex-direction: column; + min-height: 100vh; + min-height: 100dvh; } a { color: var(--accent); text-decoration: none; } a:hover { color: var(--accent-hover); text-decoration: underline; } -.wrap { - max-width: 1120px; - margin: 0 auto; - padding: 0 20px; -} - -/* ---------- Top bar ---------- */ +/* ---------- Page chrome: a slim top bar + a single full-width main area. + No persistent sidebar -- the company universe lives on its own browse + page (home.js renderHome), and opening a company navigates to a + dedicated full-page view with its own "Back to all companies" link. ---------- */ .topbar { border-bottom: 1px solid var(--border); background: var(--bg-elevated); @@ -111,29 +153,64 @@ a:hover { color: var(--accent-hover); text-decoration: underline; } align-items: center; justify-content: space-between; padding: 14px 20px; - max-width: 1120px; + max-width: 1160px; margin: 0 auto; gap: 16px; flex-wrap: wrap; } .brand { - font-weight: 700; - font-size: 1.05rem; - letter-spacing: -0.01em; + font-family: var(--font-display); + font-weight: 600; + font-style: italic; + font-size: 1.18rem; + letter-spacing: -0.005em; color: var(--text); display: flex; align-items: baseline; - gap: 8px; + gap: 9px; } .brand small { + font-family: var(--font-mono); + font-style: normal; font-weight: 500; - font-size: 0.72rem; + font-size: 0.68rem; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.06em; } .topbar-actions { display: flex; align-items: center; gap: 10px; } +.page-main { flex: 1; } +.wrap { max-width: 1160px; margin: 0 auto; padding: 28px 24px 60px; } + +.back-link { font-size: 0.85rem; display: inline-block; margin: 0 0 16px; } + +/* ---------- Full-text filing search ---------- */ +.text-search { max-width: 720px; } +.text-search-h1 { + font-family: var(--font-display); + font-weight: 600; + font-size: 1.9rem; + margin: 6px 0 10px; + letter-spacing: -0.01em; +} +.text-search-sub { color: var(--text-muted); margin: 0 0 18px; line-height: 1.55; } +.text-search input[type="text"] { width: 100%; font-size: 1rem; padding: 12px 14px; margin-bottom: 20px; } +.text-search-result { + display: block; + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 10px; + color: var(--text); +} +.text-search-result:hover { border-color: var(--accent); text-decoration: none; } +.tsr-row1 { display: flex; align-items: baseline; gap: 10px; margin-bottom: 6px; } +.tsr-ticker { font-family: var(--font-mono); font-weight: 700; font-size: 0.9rem; } +.tsr-name { color: var(--text-muted); font-size: 0.82rem; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tsr-label { font-family: var(--font-mono); color: var(--text-faint); font-size: 0.72rem; } +.tsr-snippet { margin: 0; font-family: var(--font-prose); font-size: 0.92rem; line-height: 1.55; color: var(--text-muted); } +.tsr-snippet mark { background: var(--added-bg); color: var(--added-text); } .key-status { font-size: 0.8rem; padding: 5px 10px; @@ -210,40 +287,99 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { outline-offset: 1px; } -/* ---------- Homepage ---------- */ +/* ---------- Overview (main panel, no company selected) ---------- */ .hero { - padding: 44px 28px; - margin: 20px 0; - border-radius: 16px; - background: linear-gradient(135deg, var(--accent-soft) 0%, transparent 65%); - border: 1px solid var(--border); - position: relative; - overflow: hidden; + display: grid; + grid-template-columns: 1.1fr 1fr; + gap: 32px; + align-items: center; + padding: 36px 0 44px; + margin: 8px 0 12px; + border-bottom: 1px solid var(--border); } -.hero::before { - content: ""; - position: absolute; - top: -40px; right: -40px; - width: 220px; height: 220px; - border-radius: 50%; - background: radial-gradient(circle, var(--feature-soft) 0%, transparent 70%); - pointer-events: none; +.hero-eyebrow { + font-family: var(--font-mono); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-faint); + margin-bottom: 14px; } .hero h1 { font-family: var(--font-display); - font-size: 2rem; - margin: 0 0 10px; - letter-spacing: -0.01em; - position: relative; + font-weight: 600; + font-size: 2.6rem; + line-height: 1.12; + margin: 0 0 14px; + letter-spacing: -0.015em; } -.hero p { color: var(--text-muted); max-width: 62ch; margin: 0; font-size: 0.98rem; position: relative; } +.hero p { color: var(--text-muted); max-width: 46ch; margin: 0; font-size: 1.02rem; line-height: 1.6; } .data-asof { - font-size: 0.78rem; + font-family: var(--font-mono); + font-size: 0.76rem; color: var(--text-faint); - margin-top: 12px; + margin-top: 16px; +} + +/* The hero's signature element: a real excerpt from the biggest recent + mover's actual diff -- not decoration, the product itself, front and + center. Styled like a torn-off strip of the redlined document. */ +.hero-redline { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: var(--shadow); + padding: 4px 0; position: relative; } +.hero-redline::before { + content: ""; + position: absolute; + left: 0; top: 0; bottom: 0; + width: 3px; + background: repeating-linear-gradient(180deg, var(--accent) 0 6px, transparent 6px 12px); + opacity: 0.5; +} +.hero-redline-label { + font-family: var(--font-mono); + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-faint); + padding: 12px 20px 0; +} +.hero-redline-body { padding: 8px 0 16px; } +.hero-redline-ticker { + font-family: var(--font-mono); + font-size: 0.74rem; + color: var(--text-faint); + padding: 4px 20px 10px; +} +.hero-redline-line { + display: flex; + gap: 10px; + padding: 8px 20px; + font-family: var(--font-prose); + font-size: 0.92rem; + line-height: 1.55; +} +.hero-redline-line .glyph { flex: none; font-family: var(--font-ui); font-weight: 700; } +.hero-redline-line.added { background: var(--added-bg); color: var(--added-text); } +.hero-redline-line.added .glyph { color: var(--added-border); } +.hero-redline-line.removed { background: var(--removed-bg); color: var(--removed-text); text-decoration: line-through; text-decoration-color: var(--removed-border); } +.hero-redline-line.removed .glyph { color: var(--removed-border); } +.hero-redline-link { + display: block; + font-size: 0.8rem; + font-weight: 600; + padding: 12px 20px 4px; +} +.hero-redline[hidden] { display: none; } + +@media (max-width: 800px) { + .hero { grid-template-columns: 1fr; } +} .movers-strip { margin: 28px 0 8px; } .movers-strip h2 { @@ -269,8 +405,9 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { } .mover-card { background: var(--bg-elevated); - border: 1px solid var(--feature); - border-radius: var(--radius); + border: 1px solid var(--border); + border-left: 3px solid var(--feature); + border-radius: 4px; padding: 12px 14px; display: flex; flex-direction: column; @@ -278,10 +415,30 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { transition: transform 0.15s, box-shadow 0.15s; } .mover-card:hover { transform: translateY(-2px); box-shadow: var(--shadow); } -.mover-card .ticker { font-weight: 700; font-size: 0.92rem; } -.mover-card .magnitude { font-size: 1.05rem; font-weight: 700; color: var(--feature); font-variant-numeric: tabular-nums; } +.mover-card .ticker { font-family: var(--font-mono); font-weight: 700; font-size: 0.92rem; } +.mover-card .magnitude { font-family: var(--font-mono); font-size: 1.05rem; font-weight: 700; color: var(--feature); font-variant-numeric: tabular-nums; } .mover-card .magnitude-lbl { font-size: 0.7rem; color: var(--text-faint); } +.sort-toggle { + display: flex; + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + flex: none; +} +.sort-toggle button { + background: var(--bg-elevated); + border: none; + color: var(--text-muted); + font-size: 0.82rem; + font-weight: 600; + padding: 9px 14px; + font-family: inherit; +} +.sort-toggle button + button { border-left: 1px solid var(--border); } +.sort-toggle button.active { background: var(--accent); color: #fff; } + +/* ---------- Browse page: toolbar + company card grid ---------- */ .toolbar { display: flex; gap: 10px; @@ -290,6 +447,7 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { align-items: center; } .toolbar input[type="text"] { flex: 1; min-width: 200px; } +.toolbar .text-search-cta { margin-left: auto; flex: none; } .sort-toggle { display: flex; border: 1px solid var(--border); @@ -313,65 +471,104 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; - padding-bottom: 60px; + padding-bottom: 20px; } .company-card { background: var(--bg-elevated); border: 1px solid var(--border); - border-radius: var(--radius); + border-left: 3px solid var(--border); + border-radius: 4px; padding: 16px 18px; display: flex; flex-direction: column; - gap: 10px; + gap: 8px; + color: var(--text); transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s; } -.company-card:hover { border-color: var(--accent); transform: translateY(-2px); box-shadow: var(--shadow); } -/* Only applied on first paint (see home.js) -- re-sorting reorders the same - nodes via appendChild, which can retrigger a plain `animation` on the base - class in some engines. Scoping the animation to a one-shot class that JS - strips after it plays keeps sort/filter from re-flickering the grid. */ -.company-card.entering { animation: card-in 0.3s ease both; } -@keyframes card-in { - from { opacity: 0; transform: translateY(6px); } - to { opacity: 1; transform: translateY(0); } -} -@media (prefers-reduced-motion: reduce) { - .company-card.entering { animation: none; } -} -.company-card .row1 { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; } -.company-card .ticker { font-weight: 700; font-size: 1rem; letter-spacing: -0.01em; } -.company-card .name { color: var(--text-muted); font-size: 0.83rem; line-height: 1.3; } -.company-card .sector-tag { - font-size: 0.68rem; +.company-card:hover { border-color: var(--accent); border-left-color: var(--accent); transform: translateY(-2px); box-shadow: var(--shadow); text-decoration: none; } +.cc-row1 { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.cc-ticker { font-family: var(--font-mono); font-weight: 700; font-size: 1rem; letter-spacing: -0.01em; } +.cc-sector { + font-size: 0.66rem; color: var(--text-faint); text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.04em; border: 1px solid var(--border); padding: 2px 8px; border-radius: 999px; white-space: nowrap; } -.change-badge { - display: flex; - gap: 8px; +.cc-name { color: var(--text-muted); font-size: 0.83rem; line-height: 1.3; } +.cc-change { display: flex; gap: 8px; align-items: center; font-family: var(--font-mono); font-size: 0.82rem; font-weight: 600; font-variant-numeric: tabular-nums; } +.cc-change .added { color: var(--added-border); } +.cc-change .removed { color: var(--removed-border); } +.cc-change .cc-asof { color: var(--text-faint); font-weight: 500; margin-left: auto; font-size: 0.72rem; } +.cc-change.muted { color: var(--text-faint); font-family: var(--font-ui); font-weight: 500; } + +/* ---------- Status pills (company detail header) ---------- */ +.meta-pills { display: flex; gap: 8px; flex-wrap: wrap; margin: 10px 0 8px; } +.pill { + display: inline-flex; align-items: center; - font-size: 0.82rem; + gap: 5px; + padding: 4px 11px; + border-radius: 999px; + font-size: 0.72rem; font-weight: 600; - font-variant-numeric: tabular-nums; + font-family: var(--font-mono); } -.change-badge .added { color: var(--added-border); } -.change-badge .removed { color: var(--removed-border); } -.change-badge .as-of { color: var(--text-faint); font-weight: 500; margin-left: auto; font-size: 0.72rem; } -.no-data-note { color: var(--text-faint); font-size: 0.82rem; } +.pill-success { background: var(--added-bg); color: var(--added-border); } +.pill-accent { background: var(--badge-purple-soft); color: var(--badge-purple); } +.pill-neutral { background: var(--bg); border: 1px solid var(--border); color: var(--text-faint); } +.meta-dot { color: var(--text-faint); margin: 0 2px; } .empty-state { text-align: center; - padding: 60px 20px; + padding: 40px 16px; color: var(--text-muted); + font-size: 0.85rem; } +.import-panel { + background: var(--bg-elevated); + border: 1px dashed var(--border); + border-radius: var(--radius); + padding: 16px 18px; + margin: -6px 0 30px; +} +.import-panel-status { color: var(--text-faint); font-size: 0.85rem; } +.import-panel-heading { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-faint); + font-weight: 700; + margin-bottom: 10px; +} +.import-candidates { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 10px; +} +.import-candidate { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 3px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 12px; + text-align: left; + transition: border-color 0.15s; +} +.import-candidate:hover:not(:disabled) { border-color: var(--accent); } +.import-candidate:disabled { opacity: 0.7; cursor: wait; } +.import-candidate .ticker { font-family: var(--font-mono); font-weight: 700; font-size: 0.9rem; } +.import-candidate .name { color: var(--text-muted); font-size: 0.78rem; } +.import-candidate .import-cta { color: var(--accent); font-size: 0.76rem; font-weight: 600; margin-top: 2px; } + /* ---------- Company detail ---------- */ -.back-link { font-size: 0.85rem; display: inline-block; margin: 20px 0 6px; } .company-header { display: flex; justify-content: space-between; @@ -382,9 +579,11 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { border-bottom: 1px solid var(--border); margin-bottom: 20px; } -.company-header h1 { margin: 0 0 4px; font-size: 1.5rem; letter-spacing: -0.02em; } -.company-header .meta-line { color: var(--text-muted); font-size: 0.88rem; } +.company-header h1 { font-family: var(--font-display); font-weight: 600; margin: 0 0 4px; font-size: 1.7rem; letter-spacing: -0.01em; } +.company-header h1 .ticker-sub { font-family: var(--font-mono); font-weight: 500; color: var(--text-faint); } +.company-header .meta-line { font-family: var(--font-mono); color: var(--text-muted); font-size: 0.85rem; } +.company-header-main { flex: 1; min-width: 280px; } .period-picker { display: flex; align-items: center; @@ -395,6 +594,8 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { border-radius: var(--radius); padding: 16px 18px; margin-bottom: 20px; + flex: none; + max-width: 460px; } .period-field { display: flex; flex-direction: column; gap: 5px; } .period-field label { @@ -425,23 +626,107 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { .timeline-line { width: 22px; height: 2px; background: var(--border); } .timeline-line.in-range { background: var(--accent); } -.summary-strip { +.stat-tiles { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-bottom: 12px; +} +.stat-tile { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; +} +.stat-icon { + flex: none; + width: 36px; + height: 36px; + border-radius: 50%; display: flex; - gap: 24px; - flex-wrap: wrap; + align-items: center; + justify-content: center; + font-family: var(--font-ui); + font-weight: 700; + font-size: 1.05rem; + margin-bottom: 10px; +} +.stat-icon.purple { background: var(--badge-purple-soft); color: var(--badge-purple); } +.stat-icon.orange { background: var(--badge-orange-soft); color: var(--badge-orange); } +.stat-icon.blue { background: var(--badge-blue-soft); color: var(--badge-blue); } +.stat-icon.teal { background: var(--badge-teal-soft); color: var(--badge-teal); } +.stat-tile-num { font-family: var(--font-mono); font-size: 1.5rem; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.1; color: var(--text); } +.stat-tile-lbl { color: var(--text-faint); font-size: 0.76rem; margin-top: 3px; } +.source-links-row { display: flex; gap: 16px; font-size: 0.8rem; color: var(--text-faint); margin-bottom: 20px; } + +/* ---------- Change-breakdown donut + largest-changes cards ---------- */ +.insight-row { + display: grid; + grid-template-columns: minmax(260px, 320px) 1fr; + gap: 14px; + margin-bottom: 20px; + align-items: stretch; +} +.insight-row > * { margin-bottom: 0 !important; } +.change-donut-card { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px 18px; +} +.change-donut-card h3 { + margin: 0 0 14px; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-faint); + font-weight: 700; +} +.change-donut-row { display: flex; align-items: center; gap: 16px; } +.donut-center-num { font-family: var(--font-mono); font-weight: 700; font-size: 1.3rem; fill: var(--text); } +.donut-center-lbl { font-family: var(--font-mono); font-size: 0.6rem; fill: var(--text-faint); } +.change-donut-legend { display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 0; } +.donut-legend-row { display: flex; align-items: center; gap: 7px; font-size: 0.76rem; } +.donut-dot { width: 9px; height: 9px; border-radius: 50%; flex: none; } +.donut-legend-lbl { color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.donut-legend-val { font-family: var(--font-mono); color: var(--text-faint); font-size: 0.72rem; flex: none; } + +.top-changes-card { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; margin-bottom: 20px; - font-size: 0.88rem; } -.summary-stat { display: flex; flex-direction: column; gap: 2px; } -.summary-stat .num { font-size: 1.35rem; font-weight: 700; font-variant-numeric: tabular-nums; } -.summary-stat.added .num { color: var(--added-border); } -.summary-stat.removed .num { color: var(--removed-border); } -.summary-stat .lbl { color: var(--text-faint); font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.05em; } -.source-links { margin-left: auto; display: flex; gap: 16px; align-items: center; font-size: 0.8rem; color: var(--text-faint); } +.top-changes-card h3 { + margin: 0 0 12px; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-faint); + font-weight: 700; +} +.top-changes-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; } +.top-change-item { + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 8px; +} +.tc-tag { + align-self: flex-start; + font-family: var(--font-mono); + font-size: 0.68rem; + font-weight: 700; + padding: 3px 9px; + border-radius: 999px; +} +.tc-tag.added { background: var(--added-bg); color: var(--added-border); } +.tc-tag.removed { background: var(--removed-bg); color: var(--removed-border); } +.tc-snippet { margin: 0; font-family: var(--font-prose); font-size: 0.86rem; line-height: 1.5; color: var(--text-muted); flex: 1; } +.tc-words { font-family: var(--font-mono); font-size: 0.7rem; color: var(--text-faint); } .ai-panel { background: var(--accent-soft); @@ -464,6 +749,68 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { .ai-panel .ai-caveat { margin-top: 10px; font-size: 0.76rem; color: var(--text-faint); } .ai-panel .ai-loading { color: var(--text-faint); font-style: italic; } .ai-panel .ai-error { color: var(--removed-border); } +.ai-panel.local-ai-panel { background: var(--feature-soft); border-color: var(--feature); } +.ai-panel.local-ai-panel h3 { color: var(--feature); } + +/* ---------- Peer context (inside the metrics panel) ---------- */ +.peer-context { + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--border); +} +.peer-context-lbl { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-faint); + margin-bottom: 8px; +} +.peer-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } +.peer-row-lbl { flex: none; width: 84px; font-size: 0.72rem; color: var(--text-muted); } +.peer-bar-track { flex: 1; height: 6px; border-radius: 3px; background: var(--border); overflow: hidden; } +.peer-bar-fill { height: 100%; background: var(--accent); border-radius: 3px; } +.peer-row-val { flex: none; font-family: var(--font-mono); font-size: 0.68rem; color: var(--text-faint); width: 52px; text-align: right; } + +/* ---------- Section (sub-topic) breakdown ---------- */ +.section-breakdown { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px 18px; + margin-bottom: 20px; +} +.section-breakdown h3 { + margin: 0 0 4px; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-faint); + font-weight: 700; +} +.section-breakdown-sub { margin: 0 0 14px; font-size: 0.76rem; color: var(--text-faint); } +.sb-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } +.sb-title { + flex: none; + width: 34%; + font-size: 0.8rem; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.sb-bar-track { flex: 1; height: 16px; border-radius: 3px; background: var(--bg); position: relative; overflow: hidden; } +.sb-bar-fill { height: 100%; background: var(--accent); opacity: 0.35; border-radius: 3px; } +.sb-bar-delta { + position: absolute; + left: 6px; top: 0; + height: 100%; + display: flex; + align-items: center; + font-family: var(--font-mono); + font-size: 0.66rem; + color: var(--text-muted); +} +.sb-share { flex: none; width: 40px; text-align: right; font-family: var(--font-mono); font-size: 0.74rem; color: var(--text-faint); } /* ---------- Detail layout: main column + sticky sidebar ---------- */ .detail-body { @@ -473,12 +820,14 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { align-items: start; } .detail-sidebar { + /* Sticky relative to .main-panel, which is its own scroll container in + the shell layout (there's no fixed top bar above it anymore). */ position: sticky; - top: 76px; + top: 0; display: flex; flex-direction: column; gap: 16px; - max-height: calc(100vh - 96px); + max-height: 100vh; } .metrics-panel { @@ -501,6 +850,7 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { gap: 14px 10px; } .metric-tile .val { + font-family: var(--font-mono); font-size: 1.15rem; font-weight: 700; font-variant-numeric: tabular-nums; @@ -511,6 +861,68 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { .metric-tile .delta.up { color: var(--removed-border); } .metric-tile .delta.down { color: var(--added-border); } .metric-tile .lbl { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-faint); margin-top: 2px; } +.sr-only { + position: absolute; + width: 1px; height: 1px; + padding: 0; margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.trend-charts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + margin-bottom: 20px; +} +.trend-chart-card { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 16px 8px; +} +.trend-chart-card h3 { + margin: 0 0 6px; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-faint); + font-weight: 700; +} +.trend-chart-card .chart-wrap { position: relative; } +.trend-chart { width: 100%; height: auto; display: block; overflow: visible; } +.chart-gridline { stroke: var(--border); stroke-width: 1; } +.chart-dot { pointer-events: none; } +.chart-hit { cursor: pointer; } +.chart-hit:focus { outline: none; } +.chart-end-label { + font-size: 10px; + font-weight: 700; + fill: var(--text); + font-family: var(--font-mono); +} +.chart-x-label { + font-size: 9px; + fill: var(--text-faint); + font-family: var(--font-mono); +} +.chart-empty { color: var(--text-faint); font-size: 0.8rem; padding: 20px 0; } +.chart-tooltip { + position: absolute; + top: -6px; + transform: translate(-50%, -100%); + background: var(--text); + color: var(--bg); + font-size: 0.72rem; + font-weight: 600; + padding: 4px 8px; + border-radius: 6px; + white-space: nowrap; + pointer-events: none; +} + .metrics-panel .cite { margin-top: 12px; padding-top: 12px; @@ -555,6 +967,29 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { .section-nav li a.active { border-left-color: var(--accent); color: var(--accent); font-weight: 600; background: var(--accent-soft); } .section-nav .empty-note { font-size: 0.78rem; color: var(--text-faint); } +/* ---------- Peer compare panel ---------- */ +.peer-compare { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px 18px; +} +.peer-compare h3 { + margin: 0 0 10px; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-faint); + font-weight: 700; +} +.peer-compare select { width: 100%; } +.peer-compare-table { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 0.8rem; } +.peer-compare-table th, .peer-compare-table td { padding: 6px 4px; text-align: right; } +.peer-compare-table th:first-child, .peer-compare-table td:first-child { text-align: left; color: var(--text-faint); font-size: 0.72rem; } +.peer-compare-table th { font-family: var(--font-mono); color: var(--text-muted); border-bottom: 1px solid var(--border); } +.peer-compare-table td { font-family: var(--font-mono); font-variant-numeric: tabular-nums; } +.peer-compare-note { margin: 10px 0 0; font-size: 0.7rem; color: var(--text-faint); line-height: 1.5; } + /* ---------- Diff view (the centerpiece) ---------- */ .diff-view { background: var(--bg-elevated); @@ -564,31 +999,66 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { margin-bottom: 40px; overflow: hidden; } +/* Each legend chip is styled as a miniature sample of its own chunk type + (same background tint, border, glyph) so the legend explains itself by + looking like the thing it's labeling, rather than an abstract swatch. */ .diff-legend { display: flex; - gap: 20px; - padding: 12px 24px; + gap: 10px; + padding: 14px 24px; border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} +.legend-chip { + display: flex; + align-items: center; + gap: 7px; + padding: 5px 12px 5px 9px; + border-radius: 999px; font-size: 0.78rem; + font-weight: 600; + border: 1px solid var(--border); color: var(--text-muted); - flex-wrap: wrap; } -.diff-legend span { display: flex; align-items: center; gap: 6px; } -.legend-swatch { width: 14px; height: 14px; border-radius: 3px; display: inline-block; } -.legend-swatch.added { background: var(--added-bg); border: 1px solid var(--added-border); } -.legend-swatch.removed { background: var(--removed-bg); border: 1px solid var(--removed-border); } -.legend-swatch.unchanged { background: transparent; border: 1px solid var(--border); } +.legend-glyph { + font-family: var(--font-ui); + font-weight: 700; + width: 14px; + height: 14px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.7rem; + line-height: 1; +} +.legend-count { + font-family: var(--font-mono); + font-weight: 600; + font-size: 0.72rem; + color: var(--text-faint); + padding-left: 3px; + border-left: 1px solid var(--border); + margin-left: 1px; + padding-left: 7px; +} +.legend-chip.added { background: var(--added-bg); border-color: var(--added-border); color: var(--added-text); } +.legend-chip.added .legend-glyph { background: var(--added-border); color: var(--bg-elevated); } +.legend-chip.removed { background: var(--removed-bg); border-color: var(--removed-border); color: var(--removed-text); } +.legend-chip.removed .legend-glyph { background: var(--removed-border); color: var(--bg-elevated); } +.legend-chip.unchanged { color: var(--text-muted); } +.legend-chip.unchanged .legend-glyph { background: var(--border); } .diff-body { font-family: var(--font-prose); font-size: 1.02rem; line-height: 1.75; - padding: 4px 0; + padding: 10px 0 18px; } .chunk { display: flex; gap: 12px; - padding: 10px 24px; + padding: 10px 21px; max-width: 78ch; } .chunk .glyph { @@ -601,15 +1071,43 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { user-select: none; } .chunk .txt { flex: 1; } -.chunk.added { background: var(--added-bg); border-left: 3px solid var(--added-border); } +/* Added/removed chunks read as distinct redline blocks -- rounded, + spaced apart from the surrounding prose -- rather than blending into a + continuous column of text, so a real change never gets lost next to + the unchanged paragraphs around it. */ +.chunk.added { + background: var(--added-bg); + border-left: 3px solid var(--added-border); + border-radius: 0 6px 6px 0; + margin: 3px 21px 3px 0; + padding: 12px 16px 12px 13px; +} .chunk.added .glyph { color: var(--added-border); } .chunk.added .txt { color: var(--added-text); } -.chunk.removed { background: var(--removed-bg); border-left: 3px solid var(--removed-border); } +.chunk.removed { + background: var(--removed-bg); + border-left: 3px solid var(--removed-border); + border-radius: 0 6px 6px 0; + margin: 3px 21px 3px 0; + padding: 12px 16px 12px 13px; +} .chunk.removed .glyph { color: var(--removed-border); } .chunk.removed .txt { color: var(--removed-text); text-decoration: line-through; text-decoration-color: var(--removed-border); text-decoration-thickness: 1px; } .chunk.unchanged { color: var(--unchanged-text); } .chunk.unchanged .glyph { color: var(--text-faint); } -.chunk.heading { scroll-margin-top: 76px; } +/* Section headings stick to the top of the scroll as you read through a + long filing, like a sticky list-section header, so you always know + which risk topic the paragraphs on screen belong to. */ +.chunk.heading { + position: sticky; + top: 0; + z-index: 2; + background: var(--bg-elevated); + border-bottom: 1px solid var(--border); + padding-top: 12px; + padding-bottom: 12px; + scroll-margin-top: 0; +} .chunk.heading .txt { font-family: var(--font-ui); font-weight: 700; @@ -620,6 +1118,64 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { .chunk.heading.added .txt, .chunk.heading.removed .txt { color: inherit; } .chunk.heading.removed .txt { text-decoration: line-through; text-decoration-color: var(--removed-border); } +/* Long runs of unchanged text collapse behind a native
toggle -- + accessible and keyboard-operable for free -- so the real changes aren't + buried in a wall of grey prose. */ +.unchanged-collapse { margin: 6px 21px; } +.unchanged-collapse summary { + cursor: pointer; + list-style: none; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 7px 14px; + border: 1px dashed var(--border); + border-radius: 999px; + font-family: var(--font-mono); + font-size: 0.76rem; + color: var(--text-faint); + user-select: none; +} +.unchanged-collapse summary::-webkit-details-marker { display: none; } +.unchanged-collapse summary::before { content: "\25B8"; display: inline-block; transition: transform 0.15s; } +.unchanged-collapse[open] summary::before { transform: rotate(90deg); } +.unchanged-collapse summary:hover { border-color: var(--accent); color: var(--text-muted); } +.unchanged-collapse-body { padding-top: 2px; } + +/* Floating prev/next-change nav, pinned to the bottom of the diff as you + scroll -- jumps straight between real added/removed chunks. */ +.change-nav { + position: sticky; + bottom: 16px; + width: fit-content; + margin: 10px 24px 0 auto; + display: flex; + align-items: center; + gap: 4px; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: 999px; + padding: 5px 6px 5px 12px; + box-shadow: var(--shadow); + z-index: 5; +} +.change-nav[hidden] { display: none; } +.change-nav-count { font-family: var(--font-mono); font-size: 0.72rem; color: var(--text-faint); white-space: nowrap; } +.change-nav-prev, .change-nav-next { + width: 26px; + height: 26px; + border-radius: 50%; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text-muted); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.85rem; + line-height: 1; +} +.change-nav-prev:hover, .change-nav-next:hover { border-color: var(--accent); color: var(--accent); } + .diff-body { animation: diff-in 0.25s ease both; } @keyframes diff-in { from { opacity: 0; } to { opacity: 1; } } @media (prefers-reduced-motion: reduce) { @@ -660,20 +1216,22 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { .modal .field-row input { flex: 1; } .modal-actions { display: flex; justify-content: space-between; margin-top: 18px; gap: 10px; } -/* ---------- Footer / disclaimer ---------- */ +/* ---------- Footer / disclaimer: a slim bar pinned below the shell, always + visible regardless of scroll position in either the rail or main panel. ---------- */ .site-footer { + flex: none; border-top: 1px solid var(--border); - padding: 22px 0 40px; - margin-top: 20px; + background: var(--bg-elevated); + padding: 8px 18px; } .disclaimer { - font-size: 0.78rem; + font-size: 0.7rem; color: var(--text-faint); - line-height: 1.6; - max-width: 90ch; + line-height: 1.5; + margin: 0; } .disclaimer strong { color: var(--text-muted); } -.footer-links { font-size: 0.78rem; color: var(--text-faint); margin-top: 10px; display: flex; gap: 14px; } +.disclaimer a { color: var(--text-faint); text-decoration: underline; } .loading-note, .error-note { color: var(--text-faint); @@ -695,14 +1253,23 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { } @media (max-width: 640px) { + /* Above 640px the sidebar wraps two panels side by side (see the 900px + breakpoint); below it, two 240px-min-width panels no longer fit next to + each other and would overflow the viewport instead of wrapping, so stack + them in a single column instead. */ + .detail-sidebar { flex-direction: column; } .company-header { flex-direction: column; } .period-picker { flex-direction: column; align-items: stretch; } .period-arrow { display: none; } .timeline { margin-left: 0; } - .summary-strip { flex-direction: column; gap: 12px; } - .source-links { margin-left: 0; } + .stat-tiles { grid-template-columns: repeat(2, 1fr); } + .insight-row { grid-template-columns: 1fr; } + .top-changes-grid { grid-template-columns: 1fr; } .chunk { padding: 10px 16px; max-width: none; } + .chunk.added, .chunk.removed { margin: 3px 16px 3px 0; padding: 10px 12px 10px 10px; } + .unchanged-collapse { margin: 6px 16px; } + .change-nav { margin-right: 16px; } .diff-legend { padding: 12px 16px; } - .hero { padding: 28px 20px; } - .hero h1 { font-size: 1.5rem; } + .hero h1 { font-size: 1.7rem; } + .trend-charts { grid-template-columns: 1fr; } } diff --git a/frontend/index.html b/frontend/index.html index 84779a6..dce01f2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,6 +6,9 @@ SEC Filing Risk-Factor Diff Tracker + + + @@ -25,22 +28,17 @@ -
+
+ ${renderPeerContext(peer)}
Similarity: cosine similarity of word-frequency vectors, the year-over-year 10-K comparison method used in Cohen, Malloy & Nguyen (2020, "Lazy Prices," J. of Finance) and Brown & Tucker (2011). Fog: Gunning Fog readability index (Li, 2008, "Annual Report Readability," JAE) — higher means harder to read. Simplified, deterministic implementations; not a - reimplementation of any paper's full model. + reimplementation of any paper's full model. Peer percentiles are + computed against other ${escapeHtml(peer?.sector || "sector")} companies' own most + recent precomputed metrics -- also deterministic, not a model output. +
+ `; +} + +// Real change-breakdown donut -- the actual added/removed/unchanged chunk +// counts from this diff, nothing invented. SVG stroke-dasharray segments, +// themed with the same three colors used everywhere else in the diff. +function renderChangeDonut(s) { + const total = s.added_chunks + s.removed_chunks + s.unchanged_chunks; + if (!total) return ""; + const r = 52; + const circumference = 2 * Math.PI * r; + const segs = [ + { label: "Added paragraphs", value: s.added_chunks, color: "var(--badge-purple)" }, + { label: "Removed paragraphs", value: s.removed_chunks, color: "var(--badge-orange)" }, + { label: "Unchanged paragraphs", value: s.unchanged_chunks, color: "var(--border)" }, + ]; + let offset = 0; + const arcs = segs.map((seg) => { + const len = (seg.value / total) * circumference; + const dasharray = `${len} ${circumference - len}`; + const el = ``; + offset += len; + return el; + }).join(""); + + return ` +
+

Change breakdown

+
+ + ${arcs} + ${total} + paragraphs + +
+ ${segs.map((seg) => ` +
+ + ${seg.label} + ${seg.value} · ${Math.round((seg.value / total) * 100)}% +
`).join("")} +
`; } +// Real "top changes" -- the largest added/removed paragraphs by word +// count, sorted, no invented "AI confidence." If a reader only reads one +// thing, this points at the paragraphs that moved the most text. +function renderTopChanges(chunks) { + const candidates = chunks + .filter((c) => (c.type === "added" || c.type === "removed") && !c.heading) + .map((c) => ({ ...c, words: c.text.split(/\s+/).filter(Boolean).length })) + .sort((a, b) => b.words - a.words) + .slice(0, 4); + if (!candidates.length) return ""; + return ` +
+

Largest changes (by word count)

+
+ ${candidates.map((c) => ` +
+ ${c.type === "added" ? "+ Added" : "− Removed"} +

${escapeHtml(c.text.slice(0, 160))}${c.text.length > 160 ? "…" : ""}

+ ${c.words} words +
`).join("")} +
+
`; +} + +// Honest replacement for the old "AI Scored risk heatmap" -- real word +// counts grouped under the filing's own real section headings, nothing +// scored or judged. +function renderSectionBreakdown(sections) { + if (!sections || !sections.length) return ""; + const max = Math.max(...sections.map((s) => s.word_count)); + return ` +
+

Disclosure volume by section (this filing)

+

Real word counts grouped under this filing's own section headings -- not a score or a judgment call.

+
+ ${sections.map((s) => ` +
+ ${escapeHtml(s.title)} +
+
+ ${s.added_words || s.removed_words ? `${s.added_words ? `+${s.added_words}` : ""}${s.removed_words ? ` −${s.removed_words}` : ""}` : ""} +
+ ${s.share_pct}% +
`).join("")} +
+
`; +} + +// Real peer comparison -- pick another same-sector company and see their +// own latest real analyst_metrics side by side. Reuses the same endpoints +// the rest of the page already calls; no new backend surface, no invented +// numbers. +function renderPeerComparePanel(ticker, sector) { + return ` +
+

Compare vs. a peer

+ +
+
`; +} + +async function wirePeerComparePanel(ticker, sector) { + const select = document.getElementById("peer-select"); + const result = document.getElementById("peer-compare-result"); + if (!select) return; + + try { + const data = await fetchCompanies(); + const peers = (data.companies || []) + .filter((c) => c.sector === sector && c.ticker !== ticker) + .sort((a, b) => a.ticker.localeCompare(b.ticker)); + select.innerHTML = `` + + peers.map((p) => ``).join(""); + } catch (e) { + select.innerHTML = ``; + return; + } + + select.addEventListener("change", async () => { + const peerTicker = select.value; + if (!peerTicker) { + result.innerHTML = ""; + return; + } + result.innerHTML = `
Loading ${escapeHtml(peerTicker)}…
`; + try { + const [thisCompany, peerCompany] = await Promise.all([fetchCompany(ticker), fetchCompany(peerTicker)]); + const thisLatest = thisCompany.periods.slice(-2); + const peerLatest = peerCompany.periods.slice(-2); + if (peerLatest.length < 2) { + result.innerHTML = `

${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 = ` + + + + ${rows.map(([label, a, b]) => ``).join("")} + +
${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); + const dateStr = Number.isNaN(date.getTime()) ? local.generated_at : date.toLocaleDateString(); + return ` +
+

Local AI summary (${escapeHtml(local.model)}, precomputed)

+

${escapeHtml(local.summary)}

+

Generated offline by a local model run against this diff on ${escapeHtml(dateStr)} -- served to every visitor, not tied to any API key. Not guaranteed to be accurate; verify against the original filings.

+
`; +} + function buildSectionNav(chunks) { const headings = chunks .map((c, i) => ({ ...c, i })) @@ -191,6 +440,81 @@ function buildSectionNav(chunks) { `; } +// Redesigned diff reading view: +// - long runs of unchanged paragraphs collapse behind a native
+// toggle instead of burying the real changes in a wall of grey text +// - headings render as sticky section dividers as you scroll +// - added/removed chunks get an id so the prev/next-change nav can jump +// straight to them +const UNCHANGED_COLLAPSE_THRESHOLD = 4; + +function chunkHtml(c, i) { + return ` +
+ ${GLYPH[c.type]} + ${escapeHtml(c.text)} +
`; +} + +function buildDiffBody(chunks) { + const out = []; + let run = []; + + function flushRun() { + if (!run.length) return; + if (run.length >= UNCHANGED_COLLAPSE_THRESHOLD) { + out.push(` +
+ Show ${run.length} unchanged paragraphs +
${run.map(([c, i]) => chunkHtml(c, i)).join("")}
+
`); + } else { + out.push(run.map(([c, i]) => chunkHtml(c, i)).join("")); + } + run = []; + } + + chunks.forEach((c, i) => { + if (c.type === "unchanged" && !c.heading) { + run.push([c, i]); + return; + } + flushRun(); + out.push(chunkHtml(c, i)); + }); + flushRun(); + + return out.join(""); +} + +// Floating prev/next-change nav -- jumps between real added/removed +// chunks only, skipping unchanged text (including whatever's currently +// collapsed behind a
toggle, which it opens on the way there). +function wireChangeNav(container, diffBodyEl) { + const changeChunks = [...diffBodyEl.querySelectorAll('.chunk[data-type="added"], .chunk[data-type="removed"]')]; + const nav = container.querySelector(".change-nav"); + if (!nav || changeChunks.length < 2) { + if (nav) nav.hidden = true; + return; + } + nav.hidden = false; + let idx = -1; + const counter = nav.querySelector(".change-nav-count"); + + function goTo(newIdx) { + idx = (newIdx + changeChunks.length) % changeChunks.length; + const el = changeChunks[idx]; + const details = el.closest("details"); + if (details) details.open = true; + el.scrollIntoView({ behavior: "smooth", block: "center" }); + counter.textContent = `${idx + 1} / ${changeChunks.length}`; + } + + nav.querySelector(".change-nav-prev").addEventListener("click", () => goTo(idx - 1)); + nav.querySelector(".change-nav-next").addEventListener("click", () => goTo(idx + 1)); + counter.textContent = `${changeChunks.length} changes`; +} + function setupScrollspy(diffBodyEl, sectionNavEl) { if (scrollspyObserver) scrollspyObserver.disconnect(); const links = sectionNavEl.querySelectorAll("a[data-target]"); @@ -221,7 +545,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(); @@ -229,49 +553,68 @@ function renderDiffRegion(container, ticker, fromPeriod, toPeriod, diff) { container.innerHTML = `
-
-
-
0
-
Sentences added
+
+
+
+
+
0
+
Sentences added
-
-
0
-
Sentences removed
+
+
+
0
+
Sentences removed
-
-
0
-
Paragraphs unchanged
+
+
+
0
+
Paragraphs unchanged
- + + +
+ ${renderChangeDonut(s)} + ${renderSectionBreakdown(diff.section_breakdown)} +
+ + ${renderTopChanges(diff.chunks)} + + ${renderLocalSummaryPanel(diff.local_summary)} ${aiKey ? `
-

AI summary (via your Anthropic key)

+

Your AI summary (Claude, live via your key)

Generating summary…

` : ""}
- + Added - − Removed (struck through) - Unchanged + +Added${s.added_chunks} + Removed${s.removed_chunks} + Unchanged${s.unchanged_chunks}
- ${diff.chunks.map((c, i) => ` -
- ${GLYPH[c.type]} - ${escapeHtml(c.text)} -
`).join("")} + ${buildDiffBody(diff.chunks)}
+
`; @@ -279,6 +622,11 @@ function renderDiffRegion(container, ticker, fromPeriod, toPeriod, diff) { countUp(document.getElementById("stat-added"), s.added_sentences, { duration: 450 }); countUp(document.getElementById("stat-removed"), s.removed_sentences, { duration: 450 }); countUp(document.getElementById("stat-unchanged"), s.unchanged_chunks, { duration: 450 }); + const changedTotal = s.added_sentences + s.removed_sentences; + const changePct = changedTotal + s.unchanged_chunks > 0 + ? Math.round((changedTotal / (changedTotal + s.unchanged_chunks)) * 100) + : 0; + countUp(document.getElementById("stat-changepct"), changePct, { duration: 450 }); if (m && m.similarity_score != null) { countUp(document.getElementById("metric-similarity"), m.similarity_score, { duration: 600, decimals: 1 }); countUp(document.getElementById("metric-fog"), m.fog_index_to, { duration: 600, decimals: 1 }); @@ -286,19 +634,21 @@ function renderDiffRegion(container, ticker, fromPeriod, toPeriod, diff) { } setupScrollspy(document.getElementById("diff-body"), container.querySelector(".section-nav")); + wireChangeNav(container, document.getElementById("diff-body")); + wirePeerComparePanel(ticker, sector); if (aiKey) { const panel = document.getElementById("ai-panel"); summarizeDiff(aiKey, ticker, fromPeriod.label, toPeriod.label, diff.chunks) .then((text) => { panel.innerHTML = ` -

AI summary (via your Anthropic key)

+

Your AI summary (Claude, live via your key)

${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 = ` -

AI summary (via your Anthropic key)

+

Your AI summary (Claude, live via your key)

Couldn't generate a summary: ${escapeHtml(e.message)}

`; }); } diff --git a/frontend/js/home.js b/frontend/js/home.js index 6f7d016..8924763 100644 --- a/frontend/js/home.js +++ b/frontend/js/home.js @@ -1,8 +1,8 @@ -import { fetchCompanies, fetchMeta } from "./api.js"; +import { fetchCompanies, fetchCompany, fetchDiff, fetchMeta, importCompany, searchCompanies, searchFilingText } from "./api.js"; import { countUp } from "./animate.js"; function escapeHtml(s) { - return s.replace(/[&<>"']/g, (c) => ({ + return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", }[c])); } @@ -11,30 +11,24 @@ function magnitudeOf(c) { return c.latest_change ? c.latest_change.added + c.latest_change.removed : -1; } -function companyCard(c, i) { +function companyCard(c) { const change = c.latest_change; - const changeHtml = change - ? `
- +${change.added} - −${change.removed} - as of ${escapeHtml(change.as_of || "")} -
` - : `
No comparison yet
`; - return ` - -
-
-
${escapeHtml(c.ticker)}
-
${escapeHtml(c.name)}
-
- ${escapeHtml(c.sector)} +
+
+ ${escapeHtml(c.ticker)} + ${escapeHtml(c.sector)}
- ${changeHtml} +
${escapeHtml(c.name)}
+ ${change + ? `
+${change.added}−${change.removed}as of ${escapeHtml(change.as_of || "")}
` + : `
No comparison yet
`}
`; } -function moverCard(c) { +function moverRow(c) { const change = c.latest_change; return ` @@ -44,6 +38,40 @@ function moverCard(c) { `; } +// Populates the hero's "live excerpt" with a real added/removed sentence +// pair from the biggest recent mover's latest diff -- never fabricated: if +// the real chunks don't yield a clean short excerpt, the panel just hides. +async function loadHeroExcerpt(container, ticker) { + const redline = container.querySelector("#hero-redline"); + if (!redline) return; + try { + const company = await fetchCompany(ticker); + const periods = company.periods; + if (!periods || periods.length < 2) throw new Error("no periods"); + const fromP = periods[periods.length - 2]; + const toP = periods[periods.length - 1]; + const diff = await fetchDiff(ticker, fromP.filing_id, toP.filing_id); + const added = diff.chunks.find((c) => c.type === "added" && !c.heading && c.text.length > 30 && c.text.length < 180); + const removed = diff.chunks.find((c) => c.type === "removed" && !c.heading && c.text.length > 30 && c.text.length < 180); + if (!added && !removed) throw new Error("no short excerpt available"); + + const body = redline.querySelector(".hero-redline-body"); + body.innerHTML = ` +
${escapeHtml(ticker)} · ${escapeHtml(toP.label)} 10-K
+ ${added ? `
+${escapeHtml(added.text)}
` : ""} + ${removed ? `
${escapeHtml(removed.text)}
` : ""} + See the full diff → + `; + } catch (e) { + redline.hidden = true; + } +} + +// --- The full-width browse page: hero, biggest movers, and the whole +// company universe as a searchable/sortable/filterable card grid. This is +// the only place the company list lives -- there's no persistent sidebar, +// so picking a company is a deliberate trip back to this page via +// "Back to all companies" on the detail view. --- export async function renderHome(container) { container.innerHTML = `
Loading company universe…
`; @@ -51,7 +79,7 @@ export async function renderHome(container) { try { [meta, data] = await Promise.all([fetchMeta(), fetchCompanies()]); } catch (e) { - container.innerHTML = `
Couldn't load data: ${escapeHtml(e.message)}. Has the ingestion script been run?
`; + container.innerHTML = `
Couldn't load data: ${escapeHtml(e.message)}.
`; return; } @@ -60,54 +88,65 @@ export async function renderHome(container) { const topMovers = [...companies] .filter((c) => c.latest_change) .sort((a, b) => magnitudeOf(b) - magnitudeOf(a)) - .slice(0, 4); + .slice(0, 5); container.innerHTML = ` -
-

SEC Filing Risk-Factor Diff Tracker

-

Browse how well-known public companies' disclosed risk factors (Item 1A) have - changed, filing over filing — a plain text diff, not a prediction.

-
Data as of ${escapeHtml(meta.data_as_of || "unknown")} · ${meta.company_count} companies
-
- - ${topMovers.length ? ` -
-

Biggest recent changes

-
- ${topMovers.map(moverCard).join("")} +
+
+
+
Item 1A · Risk Factors · filing-over-filing
+

What changed in the fine print.

+

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.

+
Data as of ${escapeHtml(meta.data_as_of || "unknown")} · ${meta.company_count} companies indexed
+
+
+
Live excerpt
+
+
Loading a real excerpt…
+
+
+
+ + ${topMovers.length ? ` +
+

Biggest recent changes

+
+ ${topMovers.map(moverRow).join("")} +
+
` : ""} + +
+ + +
+ + +
+ Search filing text →
-
` : ""} - -
- - -
- - + +
+ ${companies.map(companyCard).join("") || `
No companies ingested yet.
`}
-
-
- ${companies.map(companyCard).join("") || `
No companies ingested yet.
`} +
`; - const searchInput = document.getElementById("search-input"); - const sectorSelect = document.getElementById("sector-select"); - const grid = document.getElementById("company-grid"); - const sortButtons = [...document.querySelectorAll(".sort-toggle button")]; - - document.querySelectorAll(".mover-card .magnitude").forEach((el, i) => { + container.querySelectorAll(".mover-card .magnitude").forEach((el, i) => { countUp(el, magnitudeOf(topMovers[i]), { duration: 500 }); }); + if (topMovers.length) loadHeroExcerpt(container, topMovers[0].ticker); - // Entrance animation is one-shot -- strip it once played so a later - // sort/filter (which reorders these same nodes) doesn't replay the fade. - grid.querySelectorAll(".company-card.entering").forEach((card) => { - card.addEventListener("animationend", () => card.classList.remove("entering"), { once: true }); - }); + const searchInput = container.querySelector("#search-input"); + const sectorSelect = container.querySelector("#sector-select"); + const grid = container.querySelector("#company-grid"); + const sortButtons = [...container.querySelectorAll(".sort-toggle button")]; + const importPanel = container.querySelector("#import-panel"); + let searchDebounce = null; function applyFilter() { const q = searchInput.value.trim().toLowerCase(); @@ -131,16 +170,63 @@ export async function renderHome(container) { } else if (empty) { empty.remove(); } + + clearTimeout(searchDebounce); + if (visible === 0 && q.length >= 2) { + searchDebounce = setTimeout(() => showImportCandidates(q), 350); + } else { + importPanel.hidden = true; + importPanel.innerHTML = ""; + } + } + + async function showImportCandidates(q) { + importPanel.hidden = false; + importPanel.innerHTML = `
Checking SEC EDGAR for “${escapeHtml(q)}”…
`; + let results; + try { + const resData = await searchCompanies(q); + results = (resData.results || []).filter((r) => !r.is_indexed); + } catch (e) { + importPanel.innerHTML = `
Couldn't reach SEC EDGAR: ${escapeHtml(e.message)}
`; + return; + } + if (!results.length) { + importPanel.innerHTML = `
No matching SEC filer for “${escapeHtml(q)}”.
`; + return; + } + importPanel.innerHTML = ` +
Import from SEC EDGAR
+
+ ${results.map((r) => ` + `).join("")} +
`; + importPanel.querySelectorAll(".import-candidate").forEach((btn) => { + btn.addEventListener("click", () => runImport(btn.dataset.ticker, btn)); + }); + } + + async function runImport(ticker, btn) { + btn.disabled = true; + btn.querySelector(".import-cta").textContent = "Fetching from EDGAR…"; + try { + await importCompany(ticker); + window.location.hash = `#/company/${encodeURIComponent(ticker)}`; + } catch (e) { + btn.disabled = false; + btn.querySelector(".import-cta").textContent = `Failed: ${e.message}`; + } } function applySort(sortBy) { const cards = [...grid.querySelectorAll(".company-card")]; - cards.sort((a, b) => { - if (sortBy === "magnitude") { - return parseFloat(b.dataset.magnitude) - parseFloat(a.dataset.magnitude); - } - return a.dataset.ticker.localeCompare(b.dataset.ticker); - }); + cards.sort((a, b) => sortBy === "magnitude" + ? parseFloat(b.dataset.magnitude) - parseFloat(a.dataset.magnitude) + : a.dataset.ticker.localeCompare(b.dataset.ticker)); cards.forEach((card) => grid.appendChild(card)); } @@ -151,7 +237,68 @@ export async function renderHome(container) { applySort(btn.dataset.sort); }); }); - searchInput.addEventListener("input", applyFilter); sectorSelect.addEventListener("change", applyFilter); } + +// --- Full-text search across every real, already-ingested filing's Item 1A +// text -- distinct from the browse page's ticker/name filter above. --- +export function renderTextSearch(container, initialQuery) { + container.innerHTML = ` +
+ ← Back to all companies + +
+ `; + + const input = container.querySelector("#text-search-input"); + const results = container.querySelector("#text-search-results"); + let debounce = null; + + function highlight(snippetHtml, q) { + const escapedQ = q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return snippetHtml.replace(new RegExp(escapedQ, "ig"), (m) => `${m}`); + } + + async function runSearch(q) { + if (q.trim().length < 3) { + results.innerHTML = q.trim() ? `
Keep typing… (3+ characters)
` : ""; + return; + } + results.innerHTML = `
Searching…
`; + let data; + try { + data = await searchFilingText(q.trim()); + } catch (e) { + results.innerHTML = `
Search failed: ${escapeHtml(e.message)}
`; + return; + } + if (!data.results.length) { + results.innerHTML = `
No ingested filing mentions “${escapeHtml(q)}”.
`; + return; + } + results.innerHTML = data.results.map((r) => ` + +
+ ${escapeHtml(r.ticker)} + ${escapeHtml(r.name)} + ${escapeHtml(r.label)} 10-K +
+

…${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 9b000b4..7f0d715 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -1,8 +1,8 @@ import { getApiKey, setApiKey, onApiKeyChange } from "./state.js"; -import { renderHome } from "./home.js"; +import { renderHome, renderTextSearch } from "./home.js"; import { renderCompany } from "./company.js"; -const app = document.getElementById("app"); +const mainPanel = document.getElementById("main-panel"); function parseRoute() { const hash = location.hash.replace(/^#\/?/, ""); @@ -11,16 +11,22 @@ 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" }; } async function render() { const route = parseRoute(); window.scrollTo(0, 0); + if (route.view === "company") { - await renderCompany(app, route.ticker); + await renderCompany(mainPanel, route.ticker); + } else if (route.view === "search") { + renderTextSearch(mainPanel, route.q); } else { - await renderHome(app); + await renderHome(mainPanel); } } @@ -70,6 +76,9 @@ document.getElementById("clear-key-btn").addEventListener("click", () => { overlay.hidden = true; render(); }); +window.addEventListener("keydown", (e) => { + if (e.key === "Escape") overlay.hidden = true; +}); // --- Theme toggle ------------------------------------------------------- // Only a plain UI preference (not the API key) is ever persisted here -- diff --git a/requirements.txt b/requirements.txt index f603cc7..88a9f85 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ uvicorn==0.30.6 httpx==0.27.2 beautifulsoup4==4.12.3 lxml==5.3.0 +pytest==8.3.3 diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..b9649fb --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "frontend-design": { + "source": "anthropics/skills", + "sourceType": "github", + "skillPath": "skills/frontend-design/SKILL.md", + "computedHash": "4eabc66183767153e404b39d1b839b1c37f2d82d86f0a0d7e880a579d8d62336" + } + } +}