From 849b996b91dec9fde4bfd74d26a2fcf67bcf2332 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 10:55:50 +0000 Subject: [PATCH 1/4] fix: configure logging for the api process --- backend/app/main.py | 9 +++++++++ backend/app/test_main.py | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/backend/app/main.py b/backend/app/main.py index 33535303..c452a631 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + import sentry_sdk from fastapi import APIRouter, FastAPI from fastapi.routing import APIRoute @@ -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}" diff --git a/backend/app/test_main.py b/backend/app/test_main.py index 7a2f3651..2bf6cab3 100644 --- a/backend/app/test_main.py +++ b/backend/app/test_main.py @@ -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 @@ -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 From f8078b55be699ef91dd53188d92287fffdbabfef Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 10:56:43 +0000 Subject: [PATCH 2/4] fix: let a publisher's link claim a platform subdomain --- backend/app/features/collect/publishers.py | 7 +++++-- .../app/features/collect/test_publishers.py | 15 ++++++++++--- .../features/collect/test_publishers_db.py | 21 +++++++++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/backend/app/features/collect/publishers.py b/backend/app/features/collect/publishers.py index c958f345..2e0310a9 100644 --- a/backend/app/features/collect/publishers.py +++ b/backend/app/features/collect/publishers.py @@ -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 = ( @@ -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())) diff --git a/backend/app/features/collect/test_publishers.py b/backend/app/features/collect/test_publishers.py index baa35bcf..47b8ca1f 100644 --- a/backend/app/features/collect/test_publishers.py +++ b/backend/app/features/collect/test_publishers.py @@ -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: diff --git a/backend/app/features/collect/test_publishers_db.py b/backend/app/features/collect/test_publishers_db.py index a1dec259..c91173cb 100644 --- a/backend/app/features/collect/test_publishers_db.py +++ b/backend/app/features/collect/test_publishers_db.py @@ -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( @@ -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: From 3672933a720d365f5d4c503a72134a475582d932 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 10:57:44 +0000 Subject: [PATCH 3/4] fix: fail a link loudly on a non-numeric jev answer --- backend/app/core/jev.py | 8 ++++++++ backend/app/core/test_jev.py | 14 ++++++++++++-- backend/app/features/collect/newsletters.py | 3 ++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/backend/app/core/jev.py b/backend/app/core/jev.py index 0c4f3b41..1a1cea5d 100644 --- a/backend/app/core/jev.py +++ b/backend/app/core/jev.py @@ -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 diff --git a/backend/app/core/test_jev.py b/backend/app/core/test_jev.py index 384b5140..edd0f3ca 100644 --- a/backend/app/core/test_jev.py +++ b/backend/app/core/test_jev.py @@ -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 @@ -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"})]) diff --git a/backend/app/features/collect/newsletters.py b/backend/app/features/collect/newsletters.py index 55bf8681..17dafed5 100644 --- a/backend/app/features/collect/newsletters.py +++ b/backend/app/features/collect/newsletters.py @@ -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 From 38c5b1641b1b72126d80273887dcc560c7ed52eb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 10:58:18 +0000 Subject: [PATCH 4/4] docs: point stale docstrings at where the code moved --- backend/app/core/db/story.py | 2 +- backend/app/features/catalog/model.py | 2 +- frontend/src/client/schemas.gen.ts | 2 +- frontend/src/client/types.gen.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/app/core/db/story.py b/backend/app/core/db/story.py index 85e08b35..5458cc3d 100644 --- a/backend/app/core/db/story.py +++ b/backend/app/core/db/story.py @@ -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 diff --git a/backend/app/features/catalog/model.py b/backend/app/features/catalog/model.py index 4a6de95b..8b95ad2b 100644 --- a/backend/app/features/catalog/model.py +++ b/backend/app/features/catalog/model.py @@ -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" diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 7f6fd0f4..cbee545c 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -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 = { diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 0e6de889..9b947054 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -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';