Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/app/core/db/story.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class Story(SQLModel, table=True):

class StoryArticle(SQLModel, table=True):
"""An article in a story. At most one open story per article, enforced in
`catalog.stories.save_story`: a closed story keeps its articles."""
`stories.service.save_story`: a closed story keeps its articles."""

__tablename__ = "story_article"
# The primary key leads with story_id; "which story holds this article" is
Expand Down
8 changes: 8 additions & 0 deletions backend/app/core/jev.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,14 @@ def _answers(body: dict[str, Json], questions: dict[str, Question]) -> dict[str,
missing = [qid for qid, a in answers.items() if a is None]
if missing:
raise JevError(f"unanswered: {', '.join(missing)}")
# "0.93" as a string would read as 0 the same way.
odd = [
qid
for qid, q in questions.items()
if q["type"] == "noul" and not isinstance(answers[qid], int | float)
]
if odd:
raise JevError(f"non-numeric: {', '.join(odd)}")
return answers


Expand Down
14 changes: 12 additions & 2 deletions backend/app/core/test_jev.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""The Jev transport's two guarantees: a 429 waits as long as it is told to
and tries again, and an answer set with a hole in it is an error, never a
quiet zero that reads as "not a sponsor" or "off topic".
and tries again, and an answer set with a hole or a non-numeric probability in
it is an error, never a quiet zero that reads as "not a sponsor" or "off topic".
"""

from __future__ import annotations
Expand Down Expand Up @@ -107,6 +107,16 @@ def test_a_missing_answer_raises_instead_of_reading_as_zero(
jev.ask({}, QUESTIONS)


def test_a_non_numeric_probability_raises_instead_of_reading_as_zero(
monkeypatch: pytest.MonkeyPatch,
) -> None:
body = {"answers": {"is_sponsor": {"noul": "0.93"}, "kind": "article"}}
_stub(monkeypatch, [_resp(200, body)])

with pytest.raises(jev.JevError, match="is_sponsor"):
jev.ask({}, QUESTIONS)


def test_a_response_with_no_answers_raises(monkeypatch: pytest.MonkeyPatch) -> None:
_stub(monkeypatch, [_resp(200, {"error": "nope"})])

Expand Down
2 changes: 1 addition & 1 deletion backend/app/features/catalog/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
class Origin(StrEnum):
"""The feed's "From" filter: what the article is when that is settled (a
repo, a paper, a model), otherwise who published it. Not stored; each
article falls under exactly one, see `catalog.articles.origin_condition`."""
article falls under exactly one, see `catalog.rows.origin_condition`."""

lab = "lab"
company = "company"
Expand Down
3 changes: 2 additions & 1 deletion backend/app/features/collect/newsletters.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,8 @@ def prefilter(


def _probability(answers: Answers, key: str) -> float:
"""A yes/no answer as a number. A missing or non-numeric answer is 0."""
"""A yes/no answer as a number. `jev.ask` has already failed the link on a
missing or non-numeric one."""
value = answers.get(key)
return float(value) if isinstance(value, int | float) else 0.0

Expand Down
7 changes: 5 additions & 2 deletions backend/app/features/collect/publishers.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,8 @@ def _quarantine(self, slug: str, name: str) -> Publisher:
)
# Shared hosts whose subdomains are one author's site (writer.substack.com).
# Any other shared host's subdomain is the platform itself (old.reddit.com,
# m.youtube.com).
# m.youtube.com), unless a publisher's own link names it
# (engineering.linkedin.com, see `hosts_to_publishers`).
PERSONAL_SUBDOMAIN_HOSTS = frozenset({"medium.com", "substack.com"})

_HOST_PLATFORMS = (
Expand Down Expand Up @@ -313,7 +314,9 @@ def hosts_to_publishers(publishers: Iterable[Publisher]) -> dict[str, Publisher]
host = owner_key(link) or hostname(link)
else:
continue
if host and not is_shared_host(host):
# Only the bare platform: a subdomain a publisher links to
# (engineering.linkedin.com) is that publisher's own site.
if host and host not in SHARED_HOSTS:
claims.setdefault(host, {})[p.id] = p
return {
host: next(iter(pubs.values()))
Expand Down
15 changes: 12 additions & 3 deletions backend/app/features/collect/test_publishers.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,18 @@ def test_a_shared_platform_includes_its_subdomains(host: str, shared: bool) -> N
assert is_shared_host(host) is shared


def test_a_publisher_on_a_platform_subdomain_claims_nothing() -> None:
sub = _pub(11, website="https://old.reddit.com/r/LocalLLaMA")
assert _credited("https://old.reddit.com/r/LocalLLaMA/comments/x", sub) is None
@pytest.mark.parametrize(
("link", "url"),
[
("https://engineering.linkedin.com", "https://engineering.linkedin.com/blog/a"),
("https://docs.github.com/en", "https://docs.github.com/en/copilot/agents"),
("https://blog.x.com", "https://blog.x.com/engineering/en_us/a-post"),
],
)
def test_a_publishers_own_link_claims_a_platform_subdomain(link: str, url: str) -> None:
"""A platform's first-party site is the publisher's own, unlike the bare
platform host."""
assert _credited(url, _pub(11, website=link)) == 11


def test_a_substack_writer_still_claims_their_own_subdomain() -> None:
Expand Down
21 changes: 21 additions & 0 deletions backend/app/features/collect/test_publishers_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ def test_a_shared_host_that_names_no_author_stays_with_the_aggregator(
"https://m.youtube.com/watch?v=abc",
"https://youtu.be/abc",
"https://mobile.twitter.com/someone/status/1",
"https://gist.github.com/someone/abc",
],
)
def test_a_shared_platforms_subdomain_names_no_author_either(
Expand All @@ -127,6 +128,26 @@ def test_a_shared_platforms_subdomain_names_no_author_either(
assert resolver.created == []


def test_a_platform_subdomain_a_publisher_links_goes_to_that_publisher(
db: Session, sources: dict[str, Publisher]
) -> None:
"""The explicit link wins over the rule that keeps a platform's subdomains
with the aggregator."""
engineering = Publisher(
name=f"{TAG} Engineering",
slug=f"{TAG}-engineering",
kind=PublisherKind.company,
type=PublisherType.rss,
links={LinkPlatform.website: f"https://{TAG}.linkedin.com"},
)
db.add(engineering)
db.commit()
resolver = PublisherResolver(db)
url = f"https://{TAG}.linkedin.com/blog/a-post"
assert resolver.publisher_for(url, sources["aggregator"].name).id == engineering.id
assert resolver.created == []


def test_a_feeds_own_item_stays_with_the_feed(
db: Session, sources: dict[str, Publisher]
) -> None:
Expand Down
9 changes: 9 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

import logging

import sentry_sdk
from fastapi import APIRouter, FastAPI
from fastapi.routing import APIRoute
Expand All @@ -11,6 +13,13 @@
from app.entry.api import analytics, articles, likes, login, stories, users
from app.entry.mcp.server import mcp_app

# uvicorn configures only its own loggers, and collect and report print to
# stdout, so the API's INFO lines (the dev sign-in link) need this.
logging.basicConfig(level=logging.INFO)
# They log every request at INFO.
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)


def custom_generate_unique_id(route: APIRoute) -> str:
return f"{route.tags[0]}-{route.name}"
Expand Down
33 changes: 32 additions & 1 deletion backend/app/test_main.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
"""What only the whole app can show: every table is declared in `app.core.db`,
and the routes and the collect run share one engine."""
the routes and the collect run share one engine, and the API process logs at
INFO."""

from __future__ import annotations

import os
import subprocess
import sys
from pathlib import Path
from typing import get_args

from sqlmodel import SQLModel
Expand Down Expand Up @@ -30,3 +35,29 @@ def test_every_table_lives_in_core_db() -> None:
def test_routes_and_the_collect_run_share_the_engine() -> None:
assert vars(collect)["engine"] is engine
assert get_args(SessionDep)[1].dependency is get_session


def test_the_api_process_prints_the_dev_sign_in_link() -> None:
"""A fresh interpreter, because pytest installs its own log handlers. Without
a logging config the link is dropped and a developer can't sign in."""
# Empty env vars don't override `.env`, so the send is stubbed out instead:
# a developer's `.env` may hold a real Resend key.
script = (
"import logging\n"
"import app.main\n"
"assert logging.getLogger('httpx').getEffectiveLevel() == logging.WARNING\n"
"from app.features.audience import email\n"
"email.send_email = lambda **_: None\n"
"email.send_link_email('dev@example.com', "
"email.generate_sign_in_email('t0ken'), 't0ken')\n"
)
result = subprocess.run(
[sys.executable, "-c", script],
cwd=Path(__file__).parents[1],
env=os.environ | {"ENVIRONMENT": "development"},
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
assert "[dev] Sign-in link for dev@example.com" in result.stderr
assert "token=t0ken" in result.stderr
2 changes: 1 addition & 1 deletion frontend/src/client/schemas.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ export const OriginSchema = {
title: 'Origin',
description: `The feed's "From" filter: what the article is when that is settled (a
repo, a paper, a model), otherwise who published it. Not stored; each
article falls under exactly one, see \`catalog.articles.origin_condition\`.`
article falls under exactly one, see \`catalog.rows.origin_condition\`.`
} as const;

export const OriginCountSchema = {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/client/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export type Message = {
/**
* The feed's "From" filter: what the article is when that is settled (a
* repo, a paper, a model), otherwise who published it. Not stored; each
* article falls under exactly one, see `catalog.articles.origin_condition`.
* article falls under exactly one, see `catalog.rows.origin_condition`.
*/
export type Origin = 'lab' | 'company' | 'individual' | 'media' | 'repo' | 'paper' | 'model';

Expand Down
Loading