Skip to content

api: standalone read-only HTTP service for insight metrics + concepts#36

Merged
Svaag merged 2 commits into
mainfrom
feat/knowledge-read-api
Jul 11, 2026
Merged

api: standalone read-only HTTP service for insight metrics + concepts#36
Svaag merged 2 commits into
mainfrom
feat/knowledge-read-api

Conversation

@Svaag

@Svaag Svaag commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Context

Operator wants the insight metrics steerable from the Observatory instead of SSHing to run hyrule-knowledge insights .... Per the agreed design (network-operations #TBD): reads become a fast API, writes stay git. This is the read API — a standalone service (UNIX-philosophy), not folded into knowledge-mcp.

Change

New hyrule_knowledge/api.py — a thin Starlette front-end over the same functions the CLI uses (insight_policy.insight_policy_metrics, load_insight_policy_fixtures, insight_sync.fetch_collector_insights, KnowledgeStore), so CLI and API can't drift.

GET /healthz
GET /v1/insights/metrics?loop=<loop>&source=ledger|collector
      source=ledger    -> canonical IDQ/CGS/silence over committed inputs (reviewed)
      source=collector -> live metrics over the collector stream (immediate feedback)
GET /v1/knowledge/concepts/{id}  -> title + body + authority + source_refs
  • Read-only by construction — nothing here writes the ledger; durable archival stays the git-tracked, reviewed sync flow.
  • Matches the mcp_server pattern: starlette+uvicorn under a new optional api extra (already resolved in the lock via mcp), console script hyrule-knowledge-api, config via HYRULE_KNOWLEDGE_* env.
  • The source split is the crux: collector gives the operator instant feedback after labelling; ledger is the deterministic number gate promotions must use.

Tests

tests/test_api.py: source validation (400), collector-without-URL (503), the live collector path (matched decision+label → CGS 1.0, loop filter drops the soc record), the ledger-source shape, and a graceful 503 when the sqlite export is absent (never a 500). Full suite 155 passed; ruff + mypy clean; uv sync --group dev --frozen unchanged.

Next (separate PRs)

  • network-operations: deploy role for hyrule-knowledge-api on loop (mirror knowledge_mcp), firewall + monitoring.
  • agentic-observatory: /insights/metrics page consuming source=collector + source=ledger, and a "Sync now" button that dispatches the reviewed sync job.

🤖 Generated with Claude Code

A thin Starlette front-end over the exact functions the `hyrule-knowledge
insights` CLI uses, so the CLI and the API never compute different numbers.
Read-only — the durable insight ledger is still written only by the
git-tracked, reviewed sync flow.

  GET /healthz
  GET /v1/insights/metrics?loop=&source=ledger|collector
       ledger    -> canonical IDQ/CGS/silence over committed inputs
       collector -> live metrics over the collector stream
  GET /v1/knowledge/concepts/{id}  -> title+body+authority+source_refs

Matches the mcp_server pattern: starlette+uvicorn under a new optional `api`
extra (already in the lock via mcp), console script hyrule-knowledge-api,
config via HYRULE_KNOWLEDGE_* env. Tests cover source validation, the
collector-live path (CGS=1.0 on a matched decision+label, loop filter), the
ledger path shape, and graceful 503 when the export is absent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 2 🔵🔵⚪⚪⚪
🏅 Score: 92
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Nondeterministic export

_store_indexes() opens the store via open_store() which reads from the sqlite export at the config path. This is called on every /v1/insights/metrics request (line 109). If the export is being regenerated concurrently (e.g., by hyrule-knowledge export), the API may see a partially-written or stale file, producing non-reproducible metrics. The CLI avoids this because it runs as a single-shot command. For the API, the store should be opened once at startup and reused, or the export path should be snapshotted atomically.

def _store_indexes() -> tuple[dict[str, Any] | None, set[str] | None, set[str] | None]:
    """concept_index / claim_ids / context_pack_ids for the recoverability
    (faithfulness) proxy. Best-effort: a missing store just yields no verdicts,
    exactly as the CLI does."""
    from .cli import open_store
    from .store import KnowledgeStoreError

    try:
        with open_store(_config_path()) as store:
            concept_index = {str(row["id"]): row for row in store.all_concepts()}
            claim_ids = {
                str(row["id"]) for row in store.conn.execute("SELECT id FROM claims").fetchall()
            }
            context_pack_ids = {
                str(row["id"])
                for row in store.conn.execute("SELECT id FROM context_packs").fetchall()
            }
        return concept_index, claim_ids, context_pack_ids
    except KnowledgeStoreError:
        return None, None, None
Missing timeout

fetch_collector_insights() on line 97 is called without a timeout. If the collector URL is unreachable or slow, the HTTP request will hang indefinitely, blocking the API worker. This can lead to resource exhaustion under concurrent requests. A timeout (e.g., 10s) should be passed to the underlying HTTP call.

try:
    items = fetch_collector_insights(
        collector_url,
        token=os.environ.get("HYRULE_KNOWLEDGE_COLLECTOR_TOKEN") or None,
    )
except InsightSyncError as exc:
    return JSONResponse({"error": f"collector: {exc}"}, status_code=502)

@github-actions

Copy link
Copy Markdown

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Guard against missing insight_id

If a decision record lacks an insight_id key, str(row.get("insight_id")) produces
the string "None", which can cause labels to be incorrectly filtered or dropped. Add
a guard to skip records without a valid insight_id before building the ID set.

src/hyrule_knowledge/api.py [63-71]

 def _filter_loop(
     decisions: list[dict[str, Any]], labels: list[dict[str, Any]], loop: str | None
 ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
     if not loop:
         return decisions, labels
     decisions = [row for row in decisions if row.get("loop") == loop]
-    ids = {str(row.get("insight_id")) for row in decisions}
-    labels = [row for row in labels if str(row.get("insight_id")) in ids]
+    ids = {row["insight_id"] for row in decisions if "insight_id" in row}
+    labels = [row for row in labels if row.get("insight_id") in ids]
     return decisions, labels
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies a potential bug where a missing insight_id key could produce the string "None" and cause incorrect filtering. The fix is accurate and improves robustness, though the scenario may be rare in practice.

Medium

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9887313669

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread pyproject.toml

[project.optional-dependencies]
mcp = ["mcp>=1.27.0"]
api = ["starlette>=0.37", "uvicorn>=0.30"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the lockfile for the api extra

The new api extra is added in pyproject, but uv.lock was not updated, so frozen installs cannot select it. I checked uv sync --help: --extra includes optional dependencies by extra name and --frozen syncs without updating uv.lock; with the current lock metadata, uv sync --extra api --frozen --offline --no-install-project fails with Extra api is not defined, which blocks users/CI from installing the advertised API service via the checked-in lockfile.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 84cbd2d — the uv.lock update (adding the api extra to the project metadata) was left uncommitted; now committed. uv sync --extra api --frozen (the deploy role command) succeeds. Good catch — that would have broken the deploy.

Comment thread src/hyrule_knowledge/api.py Outdated
collector_url,
token=os.environ.get("HYRULE_KNOWLEDGE_COLLECTOR_TOKEN") or None,
)
except InsightSyncError as exc:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Catch collector transport errors

When HYRULE_KNOWLEDGE_COLLECTOR_URL points at a down or timing-out collector, fetch_collector_insights can raise urllib transport OSError/URLError before it raises InsightSyncError; the CLI sync path catches (InsightSyncError, OSError), but this API handler only catches InsightSyncError, so source=collector returns a 500 instead of the intended 502 collector error in that operational case.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 84cbd2d — the collector branch now catches (InsightSyncError, OSError) and returns 502, matching the CLI sync path. Test: test_metrics_collector_down_returns_502.

Comment thread src/hyrule_knowledge/api.py Outdated
Comment on lines +97 to +100
items = fetch_collector_insights(
collector_url,
token=os.environ.get("HYRULE_KNOWLEDGE_COLLECTOR_TOKEN") or None,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid blocking the event loop on collector fetches

For source=collector, this async Starlette handler calls the synchronous urllib-based fetch_collector_insights directly; when the collector is slow or waits until its 10s timeout, the uvicorn worker's event loop is blocked, so unrelated requests like /healthz and concept lookups also hang. Run this blocking fetch in a threadpool or make the route synchronous so live collector polling cannot stall the whole API process.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 84cbd2d — the blocking urllib fetch now runs via starlette.concurrency.run_in_threadpool, so a slow/down collector (up to its 10s timeout) no longer blocks the event loop / /healthz / concept lookups.

Comment thread src/hyrule_knowledge/api.py Outdated
except InsightSyncError as exc:
return JSONResponse({"error": f"collector: {exc}"}, status_code=502)
decisions = [
item.get("record", {}) for item in items if item.get("record_type") == "decision"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate collector records before computing metrics

If the collector returns a decision item whose record is missing required fields such as insight_id, this comprehension passes the raw dict through and insight_policy_metrics later indexes row["insight_id"], producing a 500 for a malformed collector payload. The sync path validates these same records with the agent-core contracts before use, so the live API path should either validate and return a collector error or skip rejected records rather than crashing.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 84cbd2d — live collector records are now validated with the same _validate_decision / _validate_label the ledger path uses (agent-core contracts + safety + created_at); malformed rows are skipped rather than crashing insight_policy_metrics. Test: test_metrics_collector_skips_malformed_records (bad record dropped, valid pair still scores CGS 1.0).

)

if source == "ledger":
decisions, labels = load_insight_policy_fixtures()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve ledger metrics from the configured bundle

When the API is launched as an installed service from a working directory that does not contain evals/fixtures/insights and ledger/insights, this call still asks load_insight_policy_fixtures() to read those relative paths from cwd, even if HYRULE_KNOWLEDGE_CONFIG points at the real repository/export. In that deployment, source=ledger silently reports empty canonical metrics rather than the committed insight rows, so resolve the ledger fixture paths relative to the configured bundle/repo instead of process cwd.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 84cbd2dmain() now os.chdirs to the configured bundle repo (parent of HYRULE_KNOWLEDGE_CONFIG) before serving, so source=ledger resolves ledger/ + evals/fixtures/ against the real checkout, not the launch cwd. (The deploy also sets WorkingDirectory to the checkout, but this makes it robust regardless.)

- Commit uv.lock with the `api` extra (was left uncommitted, so
  `uv sync --extra api --frozen` — the deploy command — failed to select it).
- Run the blocking urllib collector fetch via run_in_threadpool so a slow/down
  collector can't stall /healthz + concept lookups on the event loop.
- Catch OSError/URLError (down collector) as 502, matching the CLI, not a 500.
- Validate live collector records with the same contracts the ledger path uses;
  skip malformed rows instead of letting insight_policy_metrics crash on them.
- main() chdirs to the configured bundle repo so source=ledger resolves
  ledger/ + evals/fixtures/ regardless of launch cwd.

Tests: collector-down -> 502; malformed record skipped (valid pair still scores).
157 passed, ruff + mypy clean, uv sync --extra api --frozen green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Svaag
Svaag merged commit 5da5e30 into main Jul 11, 2026
3 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 84cbd2d660

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

continue
try:
out.append(validator(item.get("record", {}), _COLLECTOR_SOURCE))
except InsightPolicyError:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Catch contract validation failures from collector records

When the collector returns a decision or label whose record has created_at but still fails the agent-core model contract, _validate_decision/_validate_label raises pydantic's validation error rather than InsightPolicyError, so this helper lets it escape and source=collector returns a 500 instead of skipping/rejecting the bad collector row. Fresh evidence after the earlier malformed-record thread: the new helper only catches InsightPolicyError, while the model validation call inside the reused validators is still outside that exception type.

Useful? React with 👍 / 👎.

Comment on lines +134 to +135
except (InsightSyncError, OSError) as exc:
return JSONResponse({"error": f"collector: {exc}"}, status_code=502)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat malformed collector bodies as collector errors

If the collector responds with HTTP 200 but an invalid JSON body (or bytes that cannot decode as UTF-8), fetch_collector_insights raises JSONDecodeError/UnicodeDecodeError, which is not caught here, so source=collector returns a 500 even though the upstream collector response is the failing dependency. The same handler already maps bad collector transport and shape errors to 502, so include decode failures in that collector-error path as well.

Useful? React with 👍 / 👎.

Comment on lines +136 to +137
decisions = _validated_records(items, "decision", _validate_decision)
labels = _validated_records(items, "label", _validate_label)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Deduplicate live collector rows before scoring

When the live collector response contains duplicate decision or label items for the same insight_id/label_id (for example from overlapping replay/retry data, which the sync path explicitly dedupes), these raw lists are passed straight into insight_policy_metrics. That function counts raw decisions for decision_count/action counts and raw matching labels for IDQ, so source=collector can inflate counts and weight duplicated labels differently from the reviewed ledger for the same records.

Useful? React with 👍 / 👎.

# committed rows regardless of where the service was launched.
config = Path(_config_path())
if config.exists():
os.chdir(config.resolve().parent)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the resolved config path before changing cwd

When HYRULE_KNOWLEDGE_CONFIG is a relative path from the service launch directory, this chdir makes later calls to _config_path() reuse that same relative string from the new cwd. For example, launching from /srv with HYRULE_KNOWLEDGE_CONFIG=knowledge/knowledge.config.yml changes into /srv/knowledge, then request handlers try to load /srv/knowledge/knowledge/knowledge.config.yml, so concept lookups and store-backed metrics can 500 after startup; keep the resolved config path or update the env before changing cwd.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant