diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f94a2c23..8d6dcbeea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,14 @@ 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. +- Closed the repository-wide docstring-coverage gap: added the 35 missing + public docstrings the AST audit found across `lineageweave/` and + `backend/app/`. - The product-gap baseline now records private-runtime findings only as aggregate synthetic-fixture contracts and identifies the existing post-scoped lineage DAG without retaining post or organization identifiers. 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 b8c569ec2..0f083310d 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 27e56e3a0..cef8eafb8 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -114,6 +114,9 @@ / "migrations" / "0102_project_bound_summary_event.sql" ) +_TENANT_SETTINGS_MIGRATION = ( + Path(__file__).resolve().parents[2] / "migrations" / "0103_tenant_settings.sql" +) _CHANNEL_WEIGHT_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" @@ -242,6 +245,7 @@ def seeded_db(demo_analyst_token): cur.execute(_MAJOR_EVENT_ACTION_MIGRATION.read_text()) cur.execute(_PROJECT_BOUND_ACTION_MIGRATION.read_text()) cur.execute(_PROJECT_BOUND_EVENT_MIGRATION.read_text()) + cur.execute(_TENANT_SETTINGS_MIGRATION.read_text()) cur.execute(_CHANNEL_WEIGHT_MIGRATION.read_text()) cur.execute(_LEFTOVER_OBSERVED_EXPECTED_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_RANK_MIGRATION.read_text()) @@ -2091,6 +2095,51 @@ def test_nonexistent_post_is_not_found(client, demo_analyst_token) -> None: assert response.status_code == 404 +def test_settings_get_returns_the_seeded_brand_name( + client, demo_analyst_token, seeded_db +) -> None: + """An authenticated reader receives the persisted synthetic brand.""" + + response = client.get( + "/api/settings", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 200 + assert response.json() == {"brandName": "LineageWeave"} + + +def test_update_settings_requires_post_admin(client, demo_analyst_token, seeded_db) -> None: + """A non-admin reader cannot mutate tenant presentation settings.""" + + response = client.patch( + "/api/settings", + json={"brandName": "Someone Else's Brand"}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 403 + + +def test_update_settings_as_admin_changes_the_brand_name( + client, demo_analyst_token, seeded_db +) -> None: + """A post admin can persist and subsequently read a synthetic brand.""" + + _grant_post_admin(seeded_db["dsn"]) + patch_response = client.patch( + "/api/settings", + json={"brandName": "Renamed Corp"}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert patch_response.status_code == 200 + assert patch_response.json() == {"brandName": "Renamed Corp"} + + get_response = client.get( + "/api/settings", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert get_response.json() == {"brandName": "Renamed Corp"} + + def test_missing_token_is_unauthorized(client) -> None: response = client.get("/api/posts") assert response.status_code in (401, 403) @@ -5145,3 +5194,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 cc5902b66..76fb59cc0 100644 --- a/lineageweave/rankweave_client.py +++ b/lineageweave/rankweave_client.py @@ -197,6 +197,7 @@ class RankedPost: channel_evidence: tuple[RankingChannelEvidence, ...] = () 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, @@ -212,6 +213,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] @@ -348,6 +350,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.""" active_weights = weights or DEFAULT_CHANNEL_WEIGHTS try: raw = self._transport(channels, active_weights) 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": diff --git a/tests/test_public_docstrings.py b/tests/test_public_docstrings.py new file mode 100644 index 000000000..b0e557c21 --- /dev/null +++ b/tests/test_public_docstrings.py @@ -0,0 +1,52 @@ +"""Repository-wide public docstring contract.""" + +import ast +from pathlib import Path + + +_ROOT = Path(__file__).resolve().parents[1] + + +def _missing_public_docstrings(packages: tuple[Path, ...], *, root: Path) -> list[str]: + """Return source locations for public definitions without docstrings.""" + + missing: list[str] = [] + for package in packages: + for path in package.rglob("*.py"): + if path.name == "__init__.py": + continue + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if ( + isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) + and not node.name.startswith("_") + and ast.get_docstring(node) is None + ): + missing.append(f"{path.relative_to(root)}:{node.lineno}:{node.name}") + return missing + + +def test_production_public_definitions_have_docstrings() -> None: + """Keep public production definitions documented as the codebase evolves.""" + + missing = _missing_public_docstrings( + (_ROOT / "lineageweave", _ROOT / "backend" / "app"), + root=_ROOT, + ) + + assert missing == [] + + +def test_docstring_contract_reports_a_missing_public_definition(tmp_path: Path) -> None: + """Name the exact synthetic source location a beginner must repair.""" + + package = tmp_path / "synthetic_package" + package.mkdir() + (package / "module.py").write_text( + "def undocumented_public_function():\n return 'synthetic'\n", + encoding="utf-8", + ) + + assert _missing_public_docstrings((package,), root=tmp_path) == [ + "synthetic_package/module.py:1:undocumented_public_function" + ]