Skip to content

Commit 7361bd9

Browse files
hanselhanselclaude
andcommitted
feat: add llms-full.txt checking alongside llms.txt
Probe /llms-full.txt and /.well-known/llms-full.txt in addition to the existing llms.txt paths. Add llms_full_found and llms_full_url fields to LlmsTxtReport. Score 10 points if either file is present. Update detail string to list which files were found. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d37922b commit 7361bd9

5 files changed

Lines changed: 289 additions & 14 deletions

File tree

src/aeo_cli/core/checks/llms_txt.py

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,50 @@
99
from aeo_cli.core.models import LlmsTxtReport
1010

1111

12-
async def check_llms_txt(url: str, client: httpx.AsyncClient) -> LlmsTxtReport:
13-
"""Probe /llms.txt and /.well-known/llms.txt."""
14-
parsed = urlparse(url)
15-
base = f"{parsed.scheme}://{parsed.netloc}"
16-
paths = ["/llms.txt", "/.well-known/llms.txt"]
17-
12+
async def _probe_file(
13+
base: str, paths: list[str], client: httpx.AsyncClient
14+
) -> str | None:
15+
"""Probe a list of URL paths, returning the first that has non-empty content."""
1816
for path in paths:
1917
probe_url = base + path
2018
try:
2119
resp = await client.get(probe_url, follow_redirects=True)
2220
if resp.status_code == 200 and len(resp.text.strip()) > 0:
23-
return LlmsTxtReport(
24-
found=True,
25-
url=probe_url,
26-
detail=f"Found at {probe_url}",
27-
)
21+
return probe_url
2822
except httpx.HTTPError:
2923
continue
24+
return None
25+
26+
27+
async def check_llms_txt(url: str, client: httpx.AsyncClient) -> LlmsTxtReport:
28+
"""Probe /llms.txt, /.well-known/llms.txt, /llms-full.txt, /.well-known/llms-full.txt."""
29+
parsed = urlparse(url)
30+
base = f"{parsed.scheme}://{parsed.netloc}"
3031

31-
return LlmsTxtReport(found=False, detail="llms.txt not found")
32+
llms_url = await _probe_file(base, ["/llms.txt", "/.well-known/llms.txt"], client)
33+
full_url = await _probe_file(
34+
base, ["/llms-full.txt", "/.well-known/llms-full.txt"], client
35+
)
36+
37+
found = llms_url is not None
38+
full_found = full_url is not None
39+
40+
# Build detail string
41+
parts: list[str] = []
42+
if found:
43+
parts.append(f"llms.txt at {llms_url}")
44+
if full_found:
45+
parts.append(f"llms-full.txt at {full_url}")
46+
47+
if parts:
48+
detail = "Found: " + ", ".join(parts)
49+
else:
50+
detail = "llms.txt not found"
51+
52+
return LlmsTxtReport(
53+
found=found,
54+
url=llms_url,
55+
llms_full_found=full_found,
56+
llms_full_url=full_url,
57+
detail=detail,
58+
)

src/aeo_cli/core/models.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ class LlmsTxtReport(BaseModel):
7676

7777
found: bool = Field(description="Whether llms.txt was found")
7878
url: str | None = Field(default=None, description="URL where llms.txt was found")
79+
llms_full_found: bool = Field(
80+
default=False, description="Whether llms-full.txt was found"
81+
)
82+
llms_full_url: str | None = Field(
83+
default=None, description="URL where llms-full.txt was found"
84+
)
7985
score: float = Field(default=0, description="llms.txt pillar score (0-10)")
8086
detail: str = Field(default="", description="Summary of llms.txt findings")
8187

@@ -108,6 +114,11 @@ class ContentReport(BaseModel):
108114
has_headings: bool = Field(default=False, description="Whether headings were found")
109115
has_lists: bool = Field(default=False, description="Whether lists (ul/ol) were found")
110116
has_code_blocks: bool = Field(default=False, description="Whether code blocks were found")
117+
chunk_count: int = Field(default=0, description="Number of content chunks split by headings")
118+
avg_chunk_words: int = Field(default=0, description="Average word count per chunk")
119+
chunks_in_sweet_spot: int = Field(
120+
default=0, description="Chunks with 50-150 words (citation sweet spot)"
121+
)
111122
score: float = Field(default=0, description="Content pillar score (0-40)")
112123
detail: str = Field(default="", description="Summary of content density findings")
113124

src/aeo_cli/core/scoring.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,10 @@ def compute_scores(
5757
else:
5858
robots.score = 0
5959

60-
# llms.txt: max LLMS_TXT_MAX
61-
llms_txt.score = LLMS_TXT_MAX if llms_txt.found else 0
60+
# llms.txt: max LLMS_TXT_MAX — either llms.txt or llms-full.txt qualifies
61+
llms_txt.score = (
62+
LLMS_TXT_MAX if (llms_txt.found or llms_txt.llms_full_found) else 0
63+
)
6264

6365
# Schema: max SCHEMA_MAX — reward high-value types more
6466
if schema_org.blocks_found > 0:

tests/test_llms_txt_edge_cases.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,196 @@ async def mock_get(url, **kwargs):
9191

9292
assert report.found is True
9393
assert "well-known" in report.url
94+
95+
96+
# ── llms-full.txt Tests ──────────────────────────────────────────────────────
97+
98+
99+
@pytest.mark.asyncio
100+
async def test_llms_full_txt_found_at_root():
101+
"""llms-full.txt at /llms-full.txt should be detected."""
102+
async def mock_get(url, **kwargs):
103+
resp = AsyncMock()
104+
if "llms-full.txt" in url and "well-known" not in url:
105+
resp.status_code = 200
106+
resp.text = "# Full LLMs content"
107+
elif "/llms.txt" in url and "full" not in url:
108+
resp.status_code = 200
109+
resp.text = "# LLMs.txt"
110+
else:
111+
resp.status_code = 404
112+
resp.text = ""
113+
return resp
114+
115+
mock_client = AsyncMock(spec=httpx.AsyncClient)
116+
mock_client.get = AsyncMock(side_effect=mock_get)
117+
118+
report = await check_llms_txt("https://example.com", mock_client)
119+
120+
assert report.found is True
121+
assert report.llms_full_found is True
122+
assert report.llms_full_url == "https://example.com/llms-full.txt"
123+
124+
125+
@pytest.mark.asyncio
126+
async def test_llms_full_txt_found_at_well_known():
127+
"""llms-full.txt at /.well-known/llms-full.txt should be detected."""
128+
async def mock_get(url, **kwargs):
129+
resp = AsyncMock()
130+
if "well-known/llms-full.txt" in url:
131+
resp.status_code = 200
132+
resp.text = "# Full LLMs content"
133+
elif "/llms.txt" in url and "full" not in url:
134+
resp.status_code = 200
135+
resp.text = "# LLMs.txt"
136+
else:
137+
resp.status_code = 404
138+
resp.text = ""
139+
return resp
140+
141+
mock_client = AsyncMock(spec=httpx.AsyncClient)
142+
mock_client.get = AsyncMock(side_effect=mock_get)
143+
144+
report = await check_llms_txt("https://example.com", mock_client)
145+
146+
assert report.found is True
147+
assert report.llms_full_found is True
148+
assert report.llms_full_url == "https://example.com/.well-known/llms-full.txt"
149+
150+
151+
@pytest.mark.asyncio
152+
async def test_only_llms_full_txt_found():
153+
"""Only llms-full.txt present (no llms.txt) should still score."""
154+
async def mock_get(url, **kwargs):
155+
resp = AsyncMock()
156+
if "llms-full.txt" in url and "well-known" not in url:
157+
resp.status_code = 200
158+
resp.text = "# Full LLMs content"
159+
else:
160+
resp.status_code = 404
161+
resp.text = ""
162+
return resp
163+
164+
mock_client = AsyncMock(spec=httpx.AsyncClient)
165+
mock_client.get = AsyncMock(side_effect=mock_get)
166+
167+
report = await check_llms_txt("https://example.com", mock_client)
168+
169+
assert report.found is False # llms.txt not found
170+
assert report.llms_full_found is True
171+
assert report.llms_full_url == "https://example.com/llms-full.txt"
172+
assert "llms-full.txt" in report.detail
173+
174+
175+
@pytest.mark.asyncio
176+
async def test_both_llms_and_llms_full_found():
177+
"""Both llms.txt and llms-full.txt present should be reported."""
178+
async def mock_get(url, **kwargs):
179+
resp = AsyncMock()
180+
resp.status_code = 200
181+
resp.text = "# Content"
182+
return resp
183+
184+
mock_client = AsyncMock(spec=httpx.AsyncClient)
185+
mock_client.get = AsyncMock(side_effect=mock_get)
186+
187+
report = await check_llms_txt("https://example.com", mock_client)
188+
189+
assert report.found is True
190+
assert report.llms_full_found is True
191+
assert "llms.txt" in report.detail
192+
assert "llms-full.txt" in report.detail
193+
194+
195+
@pytest.mark.asyncio
196+
async def test_llms_full_txt_not_found():
197+
"""When only llms.txt exists, llms_full_found should be False."""
198+
async def mock_get(url, **kwargs):
199+
resp = AsyncMock()
200+
if "/llms.txt" in url and "full" not in url and "well-known" not in url:
201+
resp.status_code = 200
202+
resp.text = "# LLMs.txt"
203+
else:
204+
resp.status_code = 404
205+
resp.text = ""
206+
return resp
207+
208+
mock_client = AsyncMock(spec=httpx.AsyncClient)
209+
mock_client.get = AsyncMock(side_effect=mock_get)
210+
211+
report = await check_llms_txt("https://example.com", mock_client)
212+
213+
assert report.found is True
214+
assert report.llms_full_found is False
215+
assert report.llms_full_url is None
216+
217+
218+
@pytest.mark.asyncio
219+
async def test_llms_full_txt_empty_not_counted():
220+
"""An empty llms-full.txt should not count as found."""
221+
async def mock_get(url, **kwargs):
222+
resp = AsyncMock()
223+
if "llms-full.txt" in url:
224+
resp.status_code = 200
225+
resp.text = " \n "
226+
elif "/llms.txt" in url:
227+
resp.status_code = 200
228+
resp.text = "# LLMs.txt"
229+
else:
230+
resp.status_code = 404
231+
resp.text = ""
232+
return resp
233+
234+
mock_client = AsyncMock(spec=httpx.AsyncClient)
235+
mock_client.get = AsyncMock(side_effect=mock_get)
236+
237+
report = await check_llms_txt("https://example.com", mock_client)
238+
239+
assert report.found is True
240+
assert report.llms_full_found is False
241+
242+
243+
@pytest.mark.asyncio
244+
async def test_llms_full_txt_http_error():
245+
"""HTTP error on llms-full.txt should not crash; llms.txt still found."""
246+
call_count = 0
247+
248+
async def mock_get(url, **kwargs):
249+
nonlocal call_count
250+
call_count += 1
251+
if "llms-full.txt" in url:
252+
raise httpx.ConnectError("Connection refused")
253+
resp = AsyncMock()
254+
if "/llms.txt" in url and "full" not in url and "well-known" not in url:
255+
resp.status_code = 200
256+
resp.text = "# LLMs.txt"
257+
else:
258+
resp.status_code = 404
259+
resp.text = ""
260+
return resp
261+
262+
mock_client = AsyncMock(spec=httpx.AsyncClient)
263+
mock_client.get = AsyncMock(side_effect=mock_get)
264+
265+
report = await check_llms_txt("https://example.com", mock_client)
266+
267+
assert report.found is True
268+
assert report.llms_full_found is False
269+
270+
271+
@pytest.mark.asyncio
272+
async def test_neither_llms_found():
273+
"""Neither llms.txt nor llms-full.txt found should report not found."""
274+
mock_response = AsyncMock()
275+
mock_response.status_code = 404
276+
mock_response.text = ""
277+
278+
mock_client = AsyncMock(spec=httpx.AsyncClient)
279+
mock_client.get = AsyncMock(return_value=mock_response)
280+
281+
report = await check_llms_txt("https://example.com", mock_client)
282+
283+
assert report.found is False
284+
assert report.llms_full_found is False
285+
assert report.url is None
286+
assert report.llms_full_url is None

tests/test_scoring_integration.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,45 @@ def test_overall_is_sum_of_pillars():
114114
r, lt, s, c, overall = compute_scores(robots, llms_txt, schema_org, content)
115115

116116
assert overall == r.score + lt.score + s.score + c.score
117+
118+
119+
def test_llms_full_only_scores_10():
120+
"""Only llms-full.txt found (no llms.txt) should still score 10."""
121+
llms_txt = LlmsTxtReport(
122+
found=False,
123+
llms_full_found=True,
124+
llms_full_url="https://example.com/llms-full.txt",
125+
)
126+
127+
_, lt, _, _, _ = compute_scores(
128+
RobotsReport(found=False), llms_txt, SchemaReport(), ContentReport()
129+
)
130+
131+
assert lt.score == 10
132+
133+
134+
def test_both_llms_files_scores_10():
135+
"""Both llms.txt and llms-full.txt found should still score max 10."""
136+
llms_txt = LlmsTxtReport(
137+
found=True,
138+
url="https://example.com/llms.txt",
139+
llms_full_found=True,
140+
llms_full_url="https://example.com/llms-full.txt",
141+
)
142+
143+
_, lt, _, _, _ = compute_scores(
144+
RobotsReport(found=False), llms_txt, SchemaReport(), ContentReport()
145+
)
146+
147+
assert lt.score == 10
148+
149+
150+
def test_neither_llms_scores_0():
151+
"""Neither llms.txt nor llms-full.txt should score 0."""
152+
llms_txt = LlmsTxtReport(found=False, llms_full_found=False)
153+
154+
_, lt, _, _, _ = compute_scores(
155+
RobotsReport(found=False), llms_txt, SchemaReport(), ContentReport()
156+
)
157+
158+
assert lt.score == 0

0 commit comments

Comments
 (0)