From 18a7b5cc5fbd5965ab67e70cd12ea2fa3a901fb3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 23:46:15 +0900 Subject: [PATCH 1/5] fix: restore /healthz routing and close the docstring-coverage gap A stray decorator had stacked GET /healthz onto read_tenant_settings, so the liveness probe silently required auth and hit Postgres instead of returning {"status": "ok"}; the real healthz() handler had no route at all. Restored the decorator to the correct handler and added a regression test. Also closed the repository-wide docstring-coverage gap: an AST audit of lineageweave/ and backend/app/ found 35 public functions/classes missing docstrings (excluding private/dunder names, __init__.py, and tests). Added them all, plus two leftover "buyer" wording references from before the terminology rename. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011EP69xAyLaJxa6oaF6D9eq --- CHANGELOG.md | 15 +++++++++++++++ backend/app/analysis_run_start.py | 1 + backend/app/config.py | 2 ++ backend/app/main.py | 13 +++++++++++-- backend/app/post_content_queue.py | 3 +++ backend/app/post_content_worker.py | 13 +++++++++++++ backend/app/post_eligibility.py | 4 +++- backend/app/post_evaluation_ingestion.py | 3 +++ backend/tests/test_api.py | 11 +++++++++++ lineageweave/caldav_client.py | 10 +++++++++- lineageweave/chunking.py | 2 ++ lineageweave/post_structure.py | 11 +++++++++++ lineageweave/rankweave_client.py | 3 +++ lineageweave/server.py | 2 ++ 14 files changed, 89 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ed1a099..b551e42b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,21 @@ All notable changes to this project are documented here. Format follows ### Fixed +- `GET /healthz`: a stray decorator had stacked this route onto + `read_tenant_settings`, so the liveness probe silently required auth and + hit Postgres instead of returning `{"status": "ok"}`, and the real + `healthz()` handler had no route at all. Restored the decorator to the + correct handler. +- `frontend/src/App.tsx`: the pre-login screen was rendering a duplicate + `AdminPanel` with a guaranteed-empty access token (a rebase artifact); + removed it. The already-correct authenticated-view render is unaffected. + Also wired the login button through `returnUrlFromLocation()` / + `rememberOidcReturnUrl()` instead of building the return URL inline, + matching the open-redirect-safe helper the login-return-flow fix + introduced. +- Closed the repository-wide docstring-coverage gap: added the 35 missing + public docstrings the AST audit found across `lineageweave/` and + `backend/app/`. - `make smoke` and `make seed` now run through the locked project `uv` environment, so local OIDC and synthetic-data workflows resolve the same pinned dependencies as CI. diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index 2387d940b..8ebc41649 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -100,6 +100,7 @@ def configured_tepp_client(transport_url: str = "", api_key: str = "") -> TeppCl return TeppClient() def transport(payload: dict[str, Any]) -> dict[str, Any]: + """POST the TEPP wire payload to `url`, raising TeppNotAvailable on any transport failure.""" try: headers = {"authorization": f"Bearer {api_key}"} if api_key.strip() else {} return post_json(url, payload, headers=headers, timeout=30.0) diff --git a/backend/app/config.py b/backend/app/config.py index 02dc8dc34..9baf7d983 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -10,6 +10,8 @@ @dataclass(frozen=True) class Settings: + """Immutable snapshot of the backend's environment-driven configuration.""" + database_url: str # Reachable *from this backend process* -- used only to fetch JWKS # signing keys. Inside docker-compose this is the internal service DNS diff --git a/backend/app/main.py b/backend/app/main.py index fb943315f..1ba9faccd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -583,13 +583,12 @@ async def _post_filter_options( ) -@app.get("/healthz") - @app.get("/api/settings", response_model=dict) async def read_tenant_settings( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ): + """Return the tenant's current brand name, defaulting to "LineageWeave" if unset.""" async with pool.acquire() as conn: row = await conn.fetchrow("SELECT brand_name FROM tenant_settings WHERE id = 1") if not row: @@ -602,6 +601,7 @@ async def update_tenant_settings( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ): + """Admin-only: upsert the tenant's brand name and return the stored value.""" # Only admins can change settings _require_post_admin(account) brand_name = payload.get("brandName", "LineageWeave") @@ -614,6 +614,7 @@ async def update_tenant_settings( return {"brandName": brand_name} +@app.get("/healthz") async def healthz() -> dict[str, str]: """Liveness probe: the process is up. Does not touch Postgres.""" return {"status": "ok"} @@ -658,10 +659,14 @@ async def read_me( class LocalePreferenceRequest(BaseModel): + """Body of a PATCH /api/me/preferences request.""" + preferred_locale: Literal["en", "ko", "zh", "ja", "vi"] class CustomerHintResolveRequest(BaseModel): + """Body of a POST /api/customer-master/resolve-hint request.""" + hint_code: str @@ -2663,6 +2668,8 @@ async def ask_agent( class PostBookmarkRequest(BaseModel): + """Body of a POST /api/posts/{post_id}/bookmark request.""" + bookmarked: bool @@ -2672,6 +2679,7 @@ async def read_post_bookmark( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: + """Report whether the current account has bookmarked this post.""" await _load_visible_post(post_id, account, pool) async with pool.acquire() as conn: row = await conn.fetchrow( @@ -2689,6 +2697,7 @@ async def write_post_bookmark( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: + """Set or clear the current account's bookmark on this post.""" await _load_visible_post(post_id, account, pool) async with pool.acquire() as conn: if request.bookmarked: diff --git a/backend/app/post_content_queue.py b/backend/app/post_content_queue.py index dae640240..29547cfad 100644 --- a/backend/app/post_content_queue.py +++ b/backend/app/post_content_queue.py @@ -23,6 +23,8 @@ @dataclass(frozen=True) class PostContentJobRequest: + """One queued or running post-content ingestion job.""" + post_id: str source_body_sha256: str status_code: str @@ -35,6 +37,7 @@ def source_body_sha256(body: str) -> str: def post_content_api_status(status_code: str | None, *, content_present: bool) -> str: + """Map a job's internal status code to the API-facing status string.""" if status_code in _ACTIVE: return "processing" if status_code == FAILED: diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py index 458b9021f..8b7ef3202 100644 --- a/backend/app/post_content_worker.py +++ b/backend/app/post_content_worker.py @@ -210,6 +210,13 @@ async def process_post_content_job( embedding_factory: Callable[[], EmbeddingClient], structure_factory: Callable[[], PostStructureClient], ) -> None: + """Claim, run, and record the outcome of one post-content ingestion job. + + Claims the job for `post_id`/`source_body_digest` (a no-op if it is + already claimed, stale, or superseded), normalizes and persists the + post body through the given provider clients, then marks the job + succeeded or durably failed for retry. + """ settings = load_settings() row = await _claim_job( pool, @@ -283,6 +290,12 @@ async def consume_post_content_stream_once( embedding_factory: Callable[[], EmbeddingClient], structure_factory: Callable[[], PostStructureClient], ) -> str: + """Process one batch of the Valkey wake-up stream and return the new cursor. + + Reads up to 10 entries after `last_id`, runs `process_post_content_job` + for each, and returns the last-seen entry id so the caller can resume + from there on the next poll. + """ batches = await client.xread({POST_CONTENT_STREAM_KEY: last_id}, count=10, block=1000) for _stream_name, entries in batches: for entry_id, fields in entries: diff --git a/backend/app/post_eligibility.py b/backend/app/post_eligibility.py index 41473d9da..54e0a551b 100644 --- a/backend/app/post_eligibility.py +++ b/backend/app/post_eligibility.py @@ -1,4 +1,4 @@ -"""Shared source-post eligibility SQL for buyer evidence reads.""" +"""Shared source-post eligibility SQL for analysis-facing evidence reads.""" SOURCE_CONTEXT_COLUMNS = ( "source_author_code", @@ -17,12 +17,14 @@ def source_context_present_sql(alias: str) -> str: + """SQL fragment: true if any source-context column on `alias` is non-blank.""" return " or ".join( f"nullif(btrim({alias}.{column}), '') is not null" for column in SOURCE_CONTEXT_COLUMNS ) def source_context_missing_sql(alias: str) -> str: + """SQL fragment: true if every source-context column on `alias` is blank.""" return " and ".join( f"nullif(btrim({alias}.{column}), '') is null" for column in SOURCE_CONTEXT_COLUMNS ) diff --git a/backend/app/post_evaluation_ingestion.py b/backend/app/post_evaluation_ingestion.py index c290a7bd3..af6ea3993 100644 --- a/backend/app/post_evaluation_ingestion.py +++ b/backend/app/post_evaluation_ingestion.py @@ -15,6 +15,8 @@ @dataclass(frozen=True) class PersistedEvaluation: + """One persisted per-criterion LLM-as-a-Judge response for a post.""" + criterion_code: str criterion_label: str | None response_category: int @@ -50,6 +52,7 @@ async def ingest_post_evaluation( async def fetch_post_evaluation(conn: asyncpg.Connection, post_id: str) -> list[PersistedEvaluation]: + """Load this post's persisted per-criterion evaluation responses, ordered by criterion code.""" rows = await conn.fetch( """ select e.criterion_code, v.lookup_label as criterion_label, diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 438b4786a..794e1b80b 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -4973,3 +4973,14 @@ def test_post_search_matches_source_record_key_and_one_character_typo( fuzzy = client.get("/api/posts", params={"search": typo}, headers=headers) assert fuzzy.status_code == 200, fuzzy.text assert any(post["post_id"] == seeded_db["own_private_post_id"] for post in fuzzy.json()["posts"]) + + +def test_healthz_is_a_public_liveness_probe_not_tenant_settings(client) -> None: + """/healthz must stay the plain liveness probe, never the tenant-settings route it once + collided with when a stray decorator stacked onto read_tenant_settings.""" + health = client.get("/healthz") + assert health.status_code == 200 + assert health.json() == {"status": "ok"} + + unauthenticated_settings = client.get("/api/settings") + assert unauthenticated_settings.status_code in (401, 403) diff --git a/lineageweave/caldav_client.py b/lineageweave/caldav_client.py index b3bc9cffd..793462fe0 100644 --- a/lineageweave/caldav_client.py +++ b/lineageweave/caldav_client.py @@ -1,4 +1,4 @@ -"""Small independent CalDAV event-consumption port for the buyer calendar.""" +"""Small independent CalDAV event-consumption port for the organization calendar.""" from __future__ import annotations @@ -14,19 +14,26 @@ @dataclass(frozen=True) class CalDavEvent: + """One calendar event read from the configured CalDAV source.""" + event_id: str summary: str starts_at: str class NullCalDavClient: + """Fail-closed CalDAV client used when CALDAV_BASE_URL is unset; never fabricates events.""" + available = False def list_events(self) -> list[CalDavEvent]: + """Always return no events; there is no CalDAV source to query.""" return [] class HttpCalDavClient: + """CalDAV client backed by a real HTTP events endpoint.""" + available = True def __init__(self, base_url: str) -> None: @@ -36,6 +43,7 @@ def __init__(self, base_url: str) -> None: self._events_url = f"{base_url.rstrip('/')}/events" def list_events(self) -> list[CalDavEvent]: + """Fetch and parse events from the configured CalDAV endpoint.""" payload = get_json(self._events_url, timeout=10) rows = payload.get("events") if not isinstance(rows, list): diff --git a/lineageweave/chunking.py b/lineageweave/chunking.py index 6d1b7ccbc..d52c76297 100644 --- a/lineageweave/chunking.py +++ b/lineageweave/chunking.py @@ -473,6 +473,7 @@ def _split_dom_units(raw_text: str) -> list[tuple[str, int]]: current: list[str] = [] def flush() -> None: + """Join the buffered lines into one DOM unit and clear the buffer.""" if current: raw_unit = "\n".join(current) if raw_unit.strip(): @@ -513,6 +514,7 @@ def _split_plain_text_units(text: str) -> list[tuple[str, int, str]]: current: list[str] = [] def flush() -> None: + """Normalize the buffered lines into one plain-text unit and clear the buffer.""" if current: raw_unit = "\n".join(current) normalized = normalize_semantic_text(raw_unit) diff --git a/lineageweave/post_structure.py b/lineageweave/post_structure.py index 4331f65fb..3fb25e163 100644 --- a/lineageweave/post_structure.py +++ b/lineageweave/post_structure.py @@ -12,6 +12,8 @@ @dataclass(frozen=True) class StructureDecision: + """One unit's adjudicated indent level, with the evidence behind it.""" + unit_index: int indent_level: int confidence: float @@ -20,24 +22,32 @@ class StructureDecision: class PostStructureClient(Protocol): + """Port for adjudicating a post's document structure (indent levels per unit).""" + available: bool def infer( self, post_title: str, units: list[dict[str, object]] ) -> tuple[StructureDecision, ...]: + """Return one StructureDecision per unit, or raise if unavailable.""" raise NotImplementedError class NullPostStructureClient: + """Fail-closed structure client used when no orchestrator is configured.""" + available = False def infer( self, post_title: str, units: list[dict[str, object]] ) -> tuple[StructureDecision, ...]: + """Always raise; there is no structure-adjudication backend to call.""" raise RuntimeError("post structure adjudication is not available") class ContextualOrchestratorPostStructureClient: + """Structure client backed by a real contextual-orchestrator deployment.""" + available = True _DECISION_ITEM_SCHEMA = { @@ -71,6 +81,7 @@ def __init__(self, base_url: str, api_key: str, timeout: float = 600.0): def infer( self, post_title: str, units: list[dict[str, object]] ) -> tuple[StructureDecision, ...]: + """Ask the orchestrator to adjudicate an indent level for each unit.""" if not units: return () response = post_json( diff --git a/lineageweave/rankweave_client.py b/lineageweave/rankweave_client.py index eb0b3358b..e9d632c2b 100644 --- a/lineageweave/rankweave_client.py +++ b/lineageweave/rankweave_client.py @@ -124,6 +124,7 @@ class RankedPost: fused_rank: int def to_json(self) -> dict[str, Any]: + """Serialize this ranked hit to its API-facing JSON shape.""" return { "post_id": self.post_id, "post_title": self.post_title, @@ -138,6 +139,7 @@ class RankingList: items: tuple[RankedPost, ...] def to_json(self) -> list[dict[str, Any]]: + """Serialize the accepted ranking list to its API-facing JSON shape.""" return [item.to_json() for item in self.items] @@ -262,6 +264,7 @@ def fuse_rankings( titles_by_id: Mapping[str, str], weights: dict[str, float] | None = None, ) -> RankingList: + """Fuse the given per-channel id lists into one weighted RankingList.""" try: raw = self._transport(channels, weights or DEFAULT_CHANNEL_WEIGHTS) except RankWeaveNotAvailable: diff --git a/lineageweave/server.py b/lineageweave/server.py index 83b502d3d..027e9f0ad 100644 --- a/lineageweave/server.py +++ b/lineageweave/server.py @@ -49,6 +49,8 @@ def build_server(host: str = "127.0.0.1", port: int = 8420) -> ThreadingHTTPServ """Build, but do not start, the demo server.""" class Handler(BaseHTTPRequestHandler): + """Serves the demo lineage-graph JSON endpoint and the static DAG viewer.""" + def do_GET(self) -> None: # noqa: N802 """Handle a GET request using the server's health endpoint contract.""" if self.path == "/api/lineage": From 5928693477c262273e2ce3996cb8cafbe7da1c9e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 23:57:48 +0900 Subject: [PATCH 2/5] fix(frontend): preserve login return destination --- frontend/src/App.test.tsx | 6 ++++++ frontend/src/App.tsx | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 7462abd2c..68d47bb44 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import App from "./App"; import { setLocale } from "./i18n"; +import { OIDC_RETURN_URL_STORAGE_KEY } from "./oidcReturnUrl"; const signinRedirect = vi.fn(); const signoutRedirect = vi.fn(); @@ -28,11 +29,14 @@ beforeEach(() => { afterEach(() => { vi.unstubAllGlobals(); + window.sessionStorage.removeItem(OIDC_RETURN_URL_STORAGE_KEY); + window.localStorage.removeItem(OIDC_RETURN_URL_STORAGE_KEY); }); describe("App, unauthenticated", () => { it("shows a login button that starts the real OIDC redirect", async () => { render(); + expect(screen.queryByRole("heading", { name: /admin settings/i })).toBeNull(); const button = screen.getByRole("button", { name: /log in/i }); await userEvent.click(button); expect(signinRedirect).toHaveBeenCalledTimes(1); @@ -41,6 +45,8 @@ describe("App, unauthenticated", () => { state: expect.objectContaining({ returnUrl: expect.stringMatching(/^\//) }), }), ); + expect(window.sessionStorage.getItem(OIDC_RETURN_URL_STORAGE_KEY)).toMatch(/^\//); + expect(window.localStorage.getItem(OIDC_RETURN_URL_STORAGE_KEY)).toMatch(/^\//); }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6fba0dd41..1b5b351ab 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4610,7 +4610,8 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
- {destination === "admin" ? : null}