Files
All-in-one-Financial-Analysis/atlas-terminal/server/services/text_chunker.py
T
shawnkim1997andClaude Opus 4.6 b2acda81ee feat: add Atlas Terminal — Next.js 14 + FastAPI full-stack migration
Complete migration from Streamlit to Next.js 14 App Router + FastAPI backend.

Frontend (Next.js 14):
- 10 pages: Overview, Research, Valuation, Technical, Markets, Earnings, News, Portfolio, Filings, Settings
- Terminal Noir dark theme with custom Tailwind config
- TradingView Lightweight Charts for candlestick/volume
- Valuation: DCF, Sensitivity Matrix, Monte Carlo, Tornado, Reverse DCF
- Financial Statements table with YoY growth badges and margin rows
- SEC EDGAR inline filing viewer with section tabs
- News split-view with iframe article embedding
- Technical Analysis with RSI, MACD, Bollinger, Fibonacci, Moving Averages
- Earnings beat/miss visualization
- AI Copilot chat panel with Gemini integration

Backend (FastAPI):
- 13 routers: market_data, financials, valuation, technical, earnings, insider, edgar, news, portfolio, analysis, chat, estimates, fx
- Services: DCF engine, Monte Carlo simulation, sensitivity analysis, risk metrics, SEC parser, technical indicators
- yfinance + yahooquery data sources with fallback pattern
- SQLite caching layer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 02:10:10 +00:00

133 lines
3.9 KiB
Python

"""Text cleaning and chunking utilities for LLM payloads.
Provides aggressive HTML-stripping, whitespace normalisation, and
intelligent splitting of long text into sequential chunks that avoid
cutting mid-sentence when possible.
"""
import re
from typing import List
from bs4 import BeautifulSoup
def clean_text_for_llm(html_content: str) -> str:
"""Strip HTML, collapse whitespace, and remove non-ASCII for LLM input.
Removes ``<table>``, ``<img>``, ``<style>``, ``<script>``, ``<svg>``,
and ``<math>`` elements before extracting text. Drops page-number-only
lines and other layout artefacts.
Parameters
----------
html_content:
Raw HTML (or already-plain text with residual tags).
Returns
-------
str
Clean, single-line-ish text suitable for an LLM prompt.
"""
if not html_content or not html_content.strip():
return ""
try:
soup = BeautifulSoup(html_content, "lxml")
for tag in soup.find_all(["table", "img", "style", "script", "svg", "math"]):
tag.decompose()
text = soup.get_text(separator=" ")
except Exception:
text = re.sub(r"<[^>]+>", " ", html_content)
text = re.sub(r"\s+", " ", text)
text = " ".join(text.split())
text = re.sub(r"[^\x20-\x7E\n]", " ", text)
text = re.sub(r"\s+", " ", text).strip()
lines: List[str] = []
for line in text.split("\n"):
line = line.strip()
if not line:
continue
if re.fullmatch(r"\d+", line) or re.fullmatch(r"[\.\-\s\-]+", line):
continue
if re.match(r"^(page\s+\d+|\d+)\s*$", line, re.IGNORECASE) and len(line) < 20:
continue
lines.append(line)
result = " ".join(lines)
result = re.sub(r"\s+", " ", result).strip()
return result
def smart_chunk(
section: str,
max_chars: int = 10_000,
head_ratio: float = 0.5,
) -> str:
"""Truncate *section* to *max_chars* keeping head and tail portions.
When the text exceeds the limit the middle is replaced with a brief
``[ ... middle omitted ... ]`` marker. Approximately
``head_ratio * max_chars`` characters come from the start and the
remainder from the end.
Parameters
----------
section:
Full text to be trimmed.
max_chars:
Hard character budget (default 10 000 ~= 2.5k tokens).
head_ratio:
Fraction of the budget allocated to the leading portion.
Returns
-------
str
Text guaranteed to be at most *max_chars* characters long.
"""
if not section or len(section) <= max_chars:
return section
head_size = int(max_chars * head_ratio)
tail_size = max_chars - head_size - 100
return section[:head_size] + " [ ... middle omitted ... ] " + section[-tail_size:]
def _split_into_chunks(
text: str,
max_chars: int = 22_000,
min_chunk: int = 5_000,
) -> List[str]:
"""Split *text* into sequential chunks without cutting mid-sentence.
Prefers breaking at paragraph boundaries (double newlines). Each chunk
is at most *max_chars* characters; the algorithm avoids creating a
trailing fragment shorter than *min_chunk* unless it is the only chunk.
Parameters
----------
text:
The document to split.
max_chars:
Maximum characters per chunk.
min_chunk:
Minimum look-back distance when searching for a break point.
Returns
-------
List[str]
Non-empty stripped chunks in document order.
"""
if not text or len(text) <= max_chars:
return [text] if text and text.strip() else []
chunks: List[str] = []
start = 0
while start < len(text):
end = min(start + max_chars, len(text))
if end < len(text):
break_at = text.rfind("\n\n", start, end + 1)
if break_at > start + min_chunk:
end = break_at + 2
chunks.append(text[start:end].strip())
start = end
return [c for c in chunks if c]